From 374bddc1c04e28b2efdeeca24ba3e270cec6fc17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Mon, 18 May 2026 19:50:58 +0200 Subject: [PATCH 01/20] feat: add flavored markdown export rendering --- marimo/_convert/converters.py | 16 +- marimo/_convert/markdown/flavor/__init__.py | 67 +++ marimo/_convert/markdown/flavor/base.py | 165 ++++++++ marimo/_convert/markdown/flavor/myst.py | 272 ++++++++++++ marimo/_convert/markdown/flavor/pymdown.py | 291 +++++++++++++ marimo/_convert/markdown/flavor/qmd.py | 245 +++++++++++ marimo/_convert/markdown/from_ir.py | 79 ++-- marimo/_convert/markdown/to_ir.py | 115 ++++- marimo/_tutorials/markdown_format.md | 8 +- .../markdown/test_markdown_conversion.py | 50 +++ .../markdown/test_markdown_from_ir.py | 397 +++++++++++++++++- 11 files changed, 1630 insertions(+), 75 deletions(-) create mode 100644 marimo/_convert/markdown/flavor/__init__.py create mode 100644 marimo/_convert/markdown/flavor/base.py create mode 100644 marimo/_convert/markdown/flavor/myst.py create mode 100644 marimo/_convert/markdown/flavor/pymdown.py create mode 100644 marimo/_convert/markdown/flavor/qmd.py 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..85bb8e00556 --- /dev/null +++ b/marimo/_convert/markdown/flavor/__init__.py @@ -0,0 +1,67 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +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.myst import MystMarkdownFlavor +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() +_MYST_MARKDOWN = MystMarkdownFlavor() +_MARKDOWN_FLAVORS: Mapping[MarkdownFlavorName, MarkdownFlavor] = ( + MappingProxyType( + { + _PYMDOWN_MARKDOWN.name: _PYMDOWN_MARKDOWN, + _QMD_MARKDOWN.name: _QMD_MARKDOWN, + _MYST_MARKDOWN.name: _MYST_MARKDOWN, + } + ) +) +_MARKDOWN_FLAVORS_BY_EXTENSION: Mapping[str, MarkdownFlavor] = ( + MappingProxyType({".qmd": _QMD_MARKDOWN}) +) + + +def default_markdown_flavor() -> MarkdownFlavor: + return _PYMDOWN_MARKDOWN + + +def markdown_flavor_from_filename(filename: str) -> MarkdownFlavor: + """Infer the export flavor from a filename extension.""" + return _MARKDOWN_FLAVORS_BY_EXTENSION.get( + Path(filename).suffix, 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 + + +__all__ = [ + "default_markdown_flavor", + "markdown_flavor_from_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..b556012dd47 --- /dev/null +++ b/marimo/_convert/markdown/flavor/base.py @@ -0,0 +1,165 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, 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] + + +@dataclass(frozen=True) +class DirectiveBlock: + name: str + argument: str | None + options: dict[str, Any] + body: str + + +@dataclass(frozen=True) +class TabSetBlock: + tabs: list[DirectiveBlock] + + +MarkdownExportBlock = ( + MarkdownCellBlock | CodeCellBlock | DirectiveBlock | TabSetBlock +) + + +@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: metadata, markdown text, code cells, + directives, and tab groups are delegated to concrete flavors. That keeps + dialect-specific syntax out of the template method. + """ + + 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 block transforms, then block renderers. + + Consecutive markdown cells are separated by an HTML comment, while + transitions from markdown to executable/directive 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 self.transform_blocks(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 + + if isinstance(block, CodeCellBlock): + yield self.render_code_cell(block) + elif isinstance(block, TabSetBlock): + yield self.render_tab_set(block) + else: + yield self.render_directive(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 that already belongs to the target flavor. + + Cross-flavor conversion of embedded syntax belongs in + `transform_blocks`, not here, so markdown cells can first be split into + more specific block objects. For example, a target flavor may parse + source callouts or tab blocks before deciding how to render them. + """ + + @abstractmethod + def transform_blocks( + self, blocks: list[MarkdownExportBlock] + ) -> list[MarkdownExportBlock]: + """Normalize the block stream before rendering. + + Target flavors use this hook to parse source-specific blocks out of + markdown cells and group related blocks. For example, a flavor may + split PyMdown-style `///` blocks out of markdown text, then group + consecutive tab directives before rendering. + """ + + @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. + """ + + @abstractmethod + def render_directive(self, block: DirectiveBlock) -> str: + """Render a semantic directive block. + + Concrete flavors decide the target-native form. For example, a semantic + callout might become a PyMdown `///` block, a Quarto callout, a MyST + directive, or another container syntax. + """ + + @abstractmethod + def render_tab_set(self, block: TabSetBlock) -> str: + """Render a grouped tab container. + + Tabs are grouped before rendering so target flavors can emit one + container when the target supports one. Examples include Quarto panel + tabsets, MyST tab-set directives, and PyMdown tab blocks. + """ diff --git a/marimo/_convert/markdown/flavor/myst.py b/marimo/_convert/markdown/flavor/myst.py new file mode 100644 index 00000000000..dd14317b827 --- /dev/null +++ b/marimo/_convert/markdown/flavor/myst.py @@ -0,0 +1,272 @@ +"""MyST target flavor for marimo notebook exports. + +Marimo cells are emitted as MyST directives: + +```{marimo} python +:hide-code: true + +x = 1 +``` + +Syntax references: +- MyST directives, callouts, admonitions, and dropdown admonitions: + https://mystmd.org/guide/admonitions +- MyST tab-set and tab-item directives: + https://mystmd.org/docs/mystjs/dropdowns-cards-and-tabs +""" + +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + DirectiveBlock, + MarkdownCellBlock, + MarkdownExportBlock, + MarkdownExportDocument, + MarkdownFlavor, + TabSetBlock, +) +from marimo._convert.markdown.flavor.pymdown import ( + group_pymdown_tabs, + option_is_truthy, + split_pymdown_blocks, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +_MARIMO_VERSION_KEY = "marimo-version" +_CONFIG_KEYS = {"header", "pyproject"} +_MARIMO_METADATA_KEYS = {_MARIMO_VERSION_KEY, "width"} +_SCRIPT_METADATA_RE = re.compile( + r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s" + r"(?P(^#(| .*)$\s)+)^# ///$" +) + + +class MystMarkdownFlavor(MarkdownFlavor): + """Render marimo notebooks as MyST markdown. + + MyST is directive-oriented, so this flavor emits marimo cells with body + option lines instead of inline fence attributes. 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 transform_blocks( + self, blocks: list[MarkdownExportBlock] + ) -> list[MarkdownExportBlock]: + transformed: list[MarkdownExportBlock] = [] + for block in blocks: + if isinstance(block, MarkdownCellBlock): + transformed.extend(split_pymdown_blocks(block.text)) + else: + transformed.append(block) + return group_pymdown_tabs(transformed) + + def render_preamble(self, document: MarkdownExportDocument) -> list[str]: + metadata = self.prepare_metadata(document.metadata) + return [ + *_myst_frontmatter(metadata), + *_myst_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() + if not any(line.strip() for line in code_lines): + code_lines = [ + "pass" if cell.language == "python" else "-- empty cell" + ] + code = "\n".join(code_lines) + guard = "```" + while guard in code: + guard += "`" + + return "\n".join( + [ + f"{guard}{{marimo}} {cell.language}", + *[ + f":{_myst_option_name(key)}: {value}" + for key, value in cell.options.items() + ], + *([""] if cell.options else []), + code, + guard, + "", + ] + ) + + def render_directive(self, block: DirectiveBlock) -> str: + """Render PyMdown-style directives as native MyST directives. + + MyST admonition and dropdown syntax: + https://mystmd.org/guide/admonitions + """ + name = "dropdown" if block.name == "details" else block.name + head = f":::{{{name}}}" + if block.argument is not None: + head = f"{head} {block.argument}" + + options = _myst_options(block) + return "\n".join( + [ + head, + *([options, ""] if options else []), + block.body, + ":::", + "", + ] + ) + + def render_tab_set(self, block: TabSetBlock) -> str: + """Render PyMdown tabs as MyST's native tab directives. + + MyST tab-set and tab-item syntax: + https://mystmd.org/docs/mystjs/dropdowns-cards-and-tabs + """ + return "\n".join( + [ + "::::{tab-set}", + *[ + line + for tab in block.tabs + for line in [ + f":::{{tab-item}} {tab.argument or 'Tab'}", + *( + [":selected:"] + if option_is_truthy(tab.options.get("select")) + else [] + ), + tab.body, + ":::", + ] + ], + "::::", + "", + ] + ) + + +def _myst_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 _myst_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() + config = { + key: value + for key, value in {"header": header, "pyproject": pyproject}.items() + if value + } + if not config: + return [] + + body = yaml.marimo_compat_dump(config, sort_keys=False).strip() + return [ + "```{marimo-config}", + "---", + body, + "---", + "```", + "", + ] + + +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 _myst_options(block: DirectiveBlock) -> str: + admonition_type = ( + block.options.get("type") if block.name == "admonition" else None + ) + details_type = ( + block.options.get("type") if block.name == "details" else None + ) + attrs = block.options.get("attrs") + block_id = attrs.get("id") if isinstance(attrs, dict) else None + classes = attrs.get("class") if isinstance(attrs, dict) else None + class_names = [ + str(class_name) + for class_name in [admonition_type, details_type] + if class_name + ] + if classes: + class_names.extend(str(classes).split()) + + return "\n".join( + [ + *([f":class: {' '.join(class_names)}"] if class_names else []), + *( + [":open:"] + if block.name == "details" + and option_is_truthy(block.options.get("open")) + else [] + ), + *([f":label: {block_id}"] if block_id else []), + ] + ) + + +def _myst_option_name(key: str) -> str: + return key.replace("_", "-") diff --git a/marimo/_convert/markdown/flavor/pymdown.py b/marimo/_convert/markdown/flavor/pymdown.py new file mode 100644 index 00000000000..086cd667097 --- /dev/null +++ b/marimo/_convert/markdown/flavor/pymdown.py @@ -0,0 +1,291 @@ +"""PyMdown Blocks markdown flavor. + +Example source block: + +/// tip | Heads up + attrs: {id: tip-demo} + +Body +/// + +Syntax references: +- Blocks fences and options: + https://facelessuser.github.io/pymdown-extensions/extensions/blocks/ +- Blocks admonitions: + https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition/ +- Blocks details: + https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/details/ +- Blocks tabs: + https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/tab/ +- Legacy tabbed extension: + https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/ +- Obsidian-style quotes/callouts: + https://facelessuser.github.io/pymdown-extensions/extensions/quotes/ +""" + +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import re +from typing import Any + +from pymdownx.blocks import ( # type: ignore[import-untyped] + get_frontmatter as _get_pymdown_frontmatter, +) + +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + DirectiveBlock, + MarkdownCellBlock, + MarkdownExportBlock, + MarkdownExportDocument, + MarkdownFlavor, + TabSetBlock, +) +from marimo._dependencies.dependencies import DependencyManager + + +class PymdownMarkdownFlavor(MarkdownFlavor): + """Render marimo Markdown with PyMdown syntax. + + This flavor emits YAML frontmatter, fenced marimo code cells, and PyMdown + Blocks for admonitions, details, and tabs. + """ + + 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 transform_blocks( + self, blocks: list[MarkdownExportBlock] + ) -> list[MarkdownExportBlock]: + return blocks + + def render_code_cell(self, cell: CodeCellBlock) -> str: + return self._render_code_fence( + cell.source, + {"language": cell.language, **cell.options}, + ) + + def render_directive(self, block: DirectiveBlock) -> str: + return render_pymdown_directive(block) + + def render_tab_set(self, block: TabSetBlock) -> str: + return render_pymdown_tab_set(block) + + 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}="{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) + + +def render_pymdown_directive(block: DirectiveBlock) -> str: + """Render a directive using PyMdown Blocks syntax. + + Target flavors can use this helper when a directive has no more specific + target-native rendering. + """ + options = "\n".join( + f"{key}: {value}" for key, value in block.options.items() + ) + head = f"/// {block.name}" + if block.argument is not None: + head = f"{head} | {block.argument}" + + return "\n".join( + [ + head, + *([f" {options}", ""] if options else [""]), + block.body, + "///", + ] + ) + + +def render_pymdown_tab_set(block: TabSetBlock) -> str: + """Expand grouped tabs back to consecutive PyMdown tab directives.""" + return "\n\n".join(render_pymdown_directive(tab) for tab in block.tabs) + + +_PYMDOWN_BLOCK_START_RE = re.compile( + r"^(?P {0,3})(?P/{3,})[ \t]+" + r"(?P[\w-]+)[ \t]*(?:\|[ \t]*(?P.*?)[ \t]*)?$" +) +_PYMDOWN_BLOCK_END_RE = re.compile(r"^ {0,3}(?P<fence>/{3,})[ \t]*$") +_MARKDOWN_FENCE_RE = re.compile(r"^ {0,3}(?P<fence>`{3,}|~{3,})") + + +def split_pymdown_blocks(text: str) -> list[MarkdownExportBlock]: + """Split PyMdown `/// name | argument` blocks out of plain markdown.""" + lines = text.splitlines() + blocks: list[MarkdownExportBlock] = [] + pending: list[str] = [] + index = 0 + markdown_fence: str | None = None + + while index < len(lines): + line = lines[index] + fence_match = _MARKDOWN_FENCE_RE.match(line) + if fence_match is not None: + fence = fence_match.group("fence") + if markdown_fence is None: + markdown_fence = fence + elif line.strip().startswith(markdown_fence): + markdown_fence = None + pending.append(line) + index += 1 + continue + + start = ( + None + if markdown_fence is not None + else _PYMDOWN_BLOCK_START_RE.match(line) + ) + if start is None: + pending.append(line) + index += 1 + continue + + end_index = _find_pymdown_block_end( + lines, index + 1, start.group("fence") + ) + if end_index is None: + pending.append(line) + index += 1 + continue + + _append_markdown_block(blocks, pending) + body_lines = lines[index + 1 : end_index] + options, body = _extract_pymdown_options(body_lines) + blocks.append( + DirectiveBlock( + name=start.group("name").lower(), + argument=start.group("title") or None, + options=options, + body=body, + ) + ) + index = end_index + 1 + + _append_markdown_block(blocks, pending) + return blocks + + +def group_pymdown_tabs( + blocks: list[MarkdownExportBlock], +) -> list[MarkdownExportBlock]: + """Group consecutive `/// tab` blocks like PyMdown Blocks does.""" + grouped: list[MarkdownExportBlock] = [] + pending_tabs: list[DirectiveBlock] = [] + + def flush_tabs() -> None: + if pending_tabs: + grouped.append(TabSetBlock(pending_tabs.copy())) + pending_tabs.clear() + + for block in blocks: + if isinstance(block, DirectiveBlock) and block.name == "tab": + if option_is_truthy(block.options.get("new")): + flush_tabs() + pending_tabs.append(block) + continue + + flush_tabs() + grouped.append(block) + + flush_tabs() + return grouped + + +def option_is_truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"true", "1", "yes", "on"} + return bool(value) + + +def _find_pymdown_block_end( + lines: list[str], start_index: int, opening_fence: str +) -> int | None: + for index in range(start_index, len(lines)): + match = _PYMDOWN_BLOCK_END_RE.match(lines[index]) + if match is not None and len(match.group("fence")) == len( + opening_fence + ): + return index + return None + + +def _append_markdown_block( + blocks: list[MarkdownExportBlock], lines: list[str] +) -> None: + text = "\n".join(lines).strip("\n") + lines.clear() + if text: + blocks.append(MarkdownCellBlock(text)) + + +def _extract_pymdown_options(lines: list[str]) -> tuple[dict[str, Any], str]: + options: dict[str, Any] = {} + body_start = 0 + option_lines: list[str] = [] + + while body_start < len(lines): + line = lines[body_start] + if line.startswith(" ") and ":" in line: + option_lines.append(line[4:]) + body_start += 1 + continue + if not line.strip() and option_lines: + body_start += 1 + break + + if option_lines: + options = _get_pymdown_frontmatter("\n".join(option_lines)) or {} + + return options, "\n".join(lines[body_start:]).strip("\n") diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py new file mode 100644 index 00000000000..8febce4802f --- /dev/null +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -0,0 +1,245 @@ +"""Quarto markdown target flavor. + +PyMdown admonitions become Quarto callouts: + +/// tip | Heads up +Body +/// + +::: {.callout-tip title="Heads up"} +Body +::: + +Syntax references: +- Quarto callout blocks: + https://quarto.org/docs/authoring/callouts.html +- Quarto tabset panels: + https://quarto.org/docs/interactive/layout.html#tabset-panel +""" + +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING, ClassVar + +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + DirectiveBlock, + MarkdownCellBlock, + MarkdownExportBlock, + MarkdownExportDocument, + MarkdownFlavor, + TabSetBlock, +) +from marimo._convert.markdown.flavor.pymdown import ( + group_pymdown_tabs, + option_is_truthy, + split_pymdown_blocks, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class QmdMarkdownFlavor(MarkdownFlavor): + """Render marimo exports as Quarto-native markdown. + + This flavor parses PyMdown Blocks from markdown cells and maps known + semantic blocks to Quarto callouts and panel tabsets. Unknown directives + are emitted as Pandoc fenced divs. + """ + + name = "qmd" + + _callout_types: ClassVar[Mapping[str, str]] = MappingProxyType( + { + "admonition": "note", + "attention": "important", + "caution": "caution", + "danger": "caution", + "error": "caution", + "hint": "tip", + "important": "important", + "note": "note", + "tip": "tip", + "warning": "warning", + } + ) + + def transform_blocks( + self, blocks: list[MarkdownExportBlock] + ) -> list[MarkdownExportBlock]: + transformed: list[MarkdownExportBlock] = [] + for block in blocks: + if isinstance(block, MarkdownCellBlock): + transformed.extend(split_pymdown_blocks(block.text)) + else: + transformed.append(block) + return group_pymdown_tabs(transformed) + + 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}="{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) + + def render_directive(self, block: DirectiveBlock) -> str: + """Render PyMdown admonitions as Quarto callout blocks. + + Quarto callout syntax: + https://quarto.org/docs/authoring/callouts.html + """ + callout_type = self._callout_type(block) + if callout_type is None: + return _render_pandoc_div(block) + + attributes = [ + f".callout-{callout_type}", + *( + [f'title="{_escape_attribute(block.argument)}"'] + if block.argument + else [] + ), + *[ + f'{key}="{_escape_attribute(str(value))}"' + for key in ("collapse", "appearance", "icon") + for value in [block.options.get(key)] + if value is not None + ], + ] + + return "\n".join( + [ + f"::: {{{' '.join(attributes)}}}", + block.body, + ":::", + "", + ] + ) + + def render_tab_set(self, block: TabSetBlock) -> str: + """Render PyMdown tabs as a Quarto `.panel-tabset` div. + + Quarto tabset panel syntax: + https://quarto.org/docs/interactive/layout.html#tabset-panel + """ + return "\n".join( + [ + "::: {.panel-tabset}", + *[ + line + for tab in block.tabs + for line in ["", _tab_heading(tab), "", tab.body] + ], + ":::", + "", + ] + ) + + def _callout_type(self, block: DirectiveBlock) -> str | None: + if block.name == "admonition": + explicit_type = block.options.get("type") + if isinstance(explicit_type, str): + return self._callout_types.get(explicit_type) + return self._callout_types.get(block.name) + + +def _escape_attribute(value: str) -> str: + return value.replace("&", "&").replace('"', """) + + +def _render_pandoc_div(block: DirectiveBlock) -> str: + attributes = _pandoc_attributes(block) + return "\n".join( + [ + f"::: {{{' '.join(attributes)}}}", + block.body, + ":::", + "", + ] + ) + + +def _pandoc_attributes(block: DirectiveBlock) -> list[str]: + attributes = [f".{block.name}"] + if block.argument: + attributes.append(f'title="{_escape_attribute(block.argument)}"') + + for key, value in block.options.items(): + if key == "attrs" and isinstance(value, dict): + block_id = value.get("id") + if block_id: + attributes.append(f"#{block_id}") + classes = value.get("class") + if classes: + attributes.extend( + f".{class_name}" + for class_name in str(classes).split() + if class_name + ) + continue + attributes.append( + f'{key}="{_escape_attribute(_attribute_value(value))}"' + ) + return attributes + + +def _attribute_value(value: object) -> str: + if isinstance(value, bool): + return str(value).lower() + return str(value) + + +def _tab_heading(tab: DirectiveBlock) -> str: + heading = f"## {tab.argument or 'Tab'}" + if option_is_truthy(tab.options.get("select")): + return f"{heading} {{.active}}" + return heading diff --git a/marimo/_convert/markdown/from_ir.py b/marimo/_convert/markdown/from_ir.py index 35c9f762f3d..d4755fd3a11 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,13 +164,16 @@ 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: diff --git a/marimo/_convert/markdown/to_ir.py b/marimo/_convert/markdown/to_ir.py index aeb6f8fd931..fa241e64411 100644 --- a/marimo/_convert/markdown/to_ir.py +++ b/marimo/_convert/markdown/to_ir.py @@ -33,6 +33,11 @@ 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 default_markdown_flavor +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownFlavor, +) from marimo._dependencies.dependencies import DependencyManager from marimo._schemas.serialization import ( AppInstantiation, @@ -48,6 +53,10 @@ MARIMO_MD = "marimo-md" MARIMO_CODE = "marimo-code" +_MYST_MARIMO_HEADER_RE = re.compile( + r"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$" +) +_MYST_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$") ConvertKeys = Literal["marimo-ir"] @@ -74,6 +83,29 @@ def extract_attribs( return {} +def _is_myst_marimo_directive_header(line: str) -> bool: + return bool(re.match(r"^`{3,}\{marimo\}(?:\s+\w+)?\s*$", line)) + + +def _extract_myst_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 = _MYST_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 _is_code_tag(text: str) -> bool: head = text.split("\n")[0].strip() legacy_format = bool(re.search(r"\{.*python.*\}", head)) @@ -86,6 +118,9 @@ def _is_code_tag(text: str) -> bool: def _get_language(text: str) -> str: header = text.split("\n").pop(0) + myst_match = re.match(r"^`{3,}\{marimo\}\s+(?P<language>\w+)", header) + if myst_match: + return str(myst_match.group("language")) match = RE_NESTED_FENCE_START.match(header) if match and match.group("lang"): return str(match.group("lang")) @@ -95,31 +130,14 @@ def _get_language(text: str) -> str: 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]: @@ -318,6 +336,9 @@ def __init__( self.preprocessors.register( FrontMatterPreprocessor(self), "frontmatter", 100 ) + self.preprocessors.register( + MystMarimoPreprocessor(self), "myst-marimo", 99 + ) fences_ext = SuperFencesCodeExtension() fences_ext.extendMarkdown(self) # TODO: Consider adding the admonition extension, and integrating it @@ -383,6 +404,48 @@ def run(self, lines: list[str]) -> list[str]: return doc.split("\n") +class MystMarimoPreprocessor(Preprocessor): + """Normalize MyST marimo directive fences before SuperFences parses them.""" + + def run(self, lines: list[str]) -> list[str]: + normalized: list[str] = [] + index = 0 + + while index < len(lines): + match = _MYST_MARIMO_HEADER_RE.match(lines[index]) + if match is None: + normalized.append(lines[index]) + index += 1 + continue + + index += 1 + options: dict[str, str] = {} + while index < len(lines): + option = _MYST_DIRECTIVE_OPTION_RE.match(lines[index]) + if option is None: + break + options[option.group(1).replace("-", "_")] = ( + option.group(2) or "true" + ) + index += 1 + + if options and index < len(lines) and lines[index] == "": + index += 1 + + attributes = "".join( + f' {key}="{value}"' for key, value in options.items() + ) + normalized.append( + "{fence}{language} {{.marimo{attributes}}}".format( + fence=match.group("fence"), + language=match.group("language") or "python", + attributes=attributes, + ) + ) + + return normalized + + class SanitizeProcessor(Preprocessor): """Prevent unintended executable code block injection. @@ -487,9 +550,15 @@ def add_paragraph() -> None: code_block = SubElement(parent, MARIMO_CODE) block_lines = code.split("\n") - code_block.text = "\n".join(block_lines[1:-1]) + body_lines = block_lines[1:-1] attribs = extract_attribs(block_lines[0]) + if _is_myst_marimo_directive_header(block_lines[0]): + myst_options, body_lines = _extract_myst_directive_options( + body_lines + ) + attribs.update(myst_options) + code_block.text = "\n".join(body_lines) if attribs: code_block.attrib = attribs diff --git a/marimo/_tutorials/markdown_format.md b/marimo/_tutorials/markdown_format.md index d633feae2e6..f284051d1f8 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 @@ -280,4 +276,4 @@ more information on how to typeset and render markdown in marimo. ```python {.marimo hide_code="true"} import marimo as mo -``` \ No newline at end of file +``` diff --git a/tests/_convert/markdown/test_markdown_conversion.py b/tests/_convert/markdown/test_markdown_conversion.py index 404ba1fee76..7e106f41a61 100644 --- a/tests/_convert/markdown/test_markdown_conversion.py +++ b/tests/_convert/markdown/test_markdown_conversion.py @@ -115,6 +115,56 @@ 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 + + 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 + + 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..d0d84d08009 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -2,6 +2,15 @@ from __future__ import annotations from marimo._ast.app import App, InternalApp +from marimo._convert.markdown.flavor import ( + markdown_flavor_from_filename, + normalize_markdown_flavor, +) +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownCellBlock, + MarkdownExportDocument, +) from marimo._convert.markdown.from_ir import ( _format_filename_title, _get_sql_options_from_cell, @@ -305,13 +314,397 @@ def test_cell(): notebook, filename="notebook.qmd" ) assert "```{marimo .python" in markdown_qmd - assert "filters:" in markdown_qmd # qmd should have marimo filter # 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 + ) + + +def test_markdown_flavor_renders_export_document(): + """Test that the PyMdown flavor renders preamble and block syntax.""" + flavor = markdown_flavor_from_filename("notebook.md") + assert flavor.name == "pymdown" + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock("# First"), + MarkdownCellBlock("# Second"), + CodeCellBlock("x = 1", "python", {}), + ], + ) + + markdown = flavor.render_document(document) + + assert markdown.startswith("---\ntitle: Notebook\n---") + assert "# First\n<!---->\n# Second\n\n" in markdown + assert "x = 1" in markdown + + +def test_qmd_flavor_renders_export_document(): + """Test that qmd flavor renders executable fence syntax.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[CodeCellBlock("x = 1", "python", {})], + ) + + markdown = flavor.render_document(document) + + assert "```{marimo .python}" in markdown + + +def test_qmd_flavor_preserves_explicit_filters(): + """Test that qmd flavor serializes user-provided filters.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook", "filters": ["custom-filter"]}, + header=None, + blocks=[CodeCellBlock("x = 1", "python", {})], + ) + + markdown = flavor.render_document(document) + + assert markdown.startswith( + "---\ntitle: Notebook\nfilters:\n- custom-filter\n---\n" + ) + + +def test_qmd_flavor_maps_pymdown_admonitions_to_callouts(): + """Test that qmd flavor renders PyMdown admonitions as Quarto callouts.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """Before + +/// attention | Careful + +This needs attention. +/// + +After""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert '::: {.callout-important title="Careful"}' in markdown + assert "This needs attention.\n:::" in markdown + assert "Before\n\n::: {.callout-important" in markdown + assert ":::\n\nAfter" in markdown + + +def test_qmd_flavor_maps_generic_admonition_type_to_callout(): + """Test that generic PyMdown admonition type selects Quarto callout type.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// admonition | Heads up + type: warning + +Watch this. +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert '::: {.callout-warning title="Heads up"}' in markdown + assert "Watch this.\n:::" in markdown + + +def test_pymdown_flavor_preserves_pymdown_admonitions(): + """Test that pymdown flavor keeps PyMdown syntax unchanged.""" + flavor = markdown_flavor_from_filename("notebook.md") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// tip | Keep this + +PyMdown syntax is preserved. +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert "/// tip | Keep this" in markdown + + +def test_convert_from_ir_to_markdown_maps_admonitions_for_qmd(): + """Test full export maps PyMdown markdown admonitions for qmd output.""" + app = App() + + @app.cell() + def __(): + import marimo as mo + + return (mo,) + + @app.cell() + def __(mo): + mo.md( + """ + /// tip | Tip with Title + + This is an example. + /// + """ + ) + return + + internal_app = InternalApp(app) + notebook = internal_app.to_ir() + + markdown = convert_from_ir_to_markdown(notebook, filename="notebook.qmd") + + assert '::: {.callout-tip title="Tip with Title"}' in markdown + assert "This is an example.\n:::" in markdown + + +def test_qmd_flavor_maps_pymdown_tabs_to_panel_tabsets(): + """Test that consecutive PyMdown tabs become a Quarto panel tabset.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// tab | Python +print("py") +/// + +/// tab | SQL + select: true + +select 1 +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert "::: {.panel-tabset}" in markdown + assert '## Python\n\nprint("py")' in markdown + assert "## SQL {.active}\n\nselect 1" in markdown + + +def test_qmd_flavor_starts_new_tabset_for_pymdown_new_tab_option(): + """Test that PyMdown tab new option starts another Quarto tabset.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// tab | A +A +/// + +/// tab | B + new: true + +B +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert markdown.count("::: {.panel-tabset}") == 2 + assert "## A\n\nA" in markdown + assert "## B\n\nB" in markdown + + +def test_qmd_flavor_falls_back_to_pandoc_divs(): + """Test that unmapped PyMdown directives become Quarto-compatible divs.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// details | More + attrs: {id: more, class: folded quiet} + open: true + +Body +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert ( + '::: {.details title="More" #more .folded .quiet open="true"}' + in markdown + ) + assert "Body\n:::" in markdown + + +def test_mystmd_flavor_maps_pymdown_blocks_to_myst_directives(): + """Test that mystmd flavor maps PyMdown blocks to MyST directives.""" + flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// tip | Nice +Body +/// + +/// tab | Python +print("py") +/// + +/// tab | SQL +select 1 +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert ":::{tip} Nice\nBody\n:::" in markdown + assert "::::{tab-set}" in markdown + assert ':::{tab-item} Python\nprint("py")\n:::' in markdown + assert ":::{tab-item} SQL\nselect 1\n:::" in markdown + + +def test_mystmd_flavor_renders_marimo_notebook_export_syntax(): + """Test that mystmd flavor renders marimo notebook authoring syntax.""" + flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") + pep723_header = ( + "import os", + "# /// script", + '# requires-python = ">=3.10"', + "# dependencies = [", + '# "pandas",', + "# ]", + "# ///", + ) + document = MarkdownExportDocument( + metadata={ + "title": "Notebook", + "marimo-version": "0.0.0", + "width": "medium", + "header": "\n".join(pep723_header), + }, + header=None, + blocks=[ + CodeCellBlock( + source="x = 1", + language="python", + options={"hide_code": "true", "unparsable": "true"}, + ) + ], + ) + + markdown = flavor.render_document(document) + + assert markdown.startswith("---\ntitle: Notebook\n---\n") + assert "```{marimo-config}\n---\n" in markdown + assert "header: |-\n import os" in markdown + assert 'requires-python = ">=3.10"' in markdown + expected_cell = ( + "```{marimo} python", + ":hide-code: true", + ":unparsable: true", + "", + "x = 1", + "```", + ) + assert "\n".join(expected_cell) in markdown + + +def test_mystmd_flavor_grows_code_fence_guard(): + """Test that mystmd code fences are valid when source contains backticks.""" + flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + CodeCellBlock( + source='mo.md("""\n```python\nx = 1\n```\n""")', + language="python", + options={}, + ) + ], + ) + + markdown = flavor.render_document(document) + + assert "````{marimo} python" in markdown + assert markdown.rstrip().endswith("````") + + +def test_mystmd_flavor_merges_classes_into_one_option(): + """Test that MyST directives do not repeat the class option.""" + flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// admonition | Heads up + type: tip + attrs: {class: extra} + +Body +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert ( + ":::{admonition} Heads up\n:class: tip extra\n\nBody\n:::" in markdown + ) From 8bd0daad42fd3d7f16018e0bfd38287ee7a9ea78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Mon, 18 May 2026 19:51:15 +0200 Subject: [PATCH 02/20] feat: expose markdown flavors through export surfaces --- marimo/_cli/export/commands.py | 17 +++++++++++++++-- marimo/_pyodide/pyodide_session.py | 11 ++++++++--- marimo/_server/api/endpoints/export.py | 7 +++++-- marimo/_server/export/__init__.py | 7 +++++-- marimo/_server/models/export.py | 4 +++- packages/openapi/api.yaml | 13 ++++++++++--- packages/openapi/src/api.ts | 5 ++++- tests/_cli/test_cli_export.py | 15 +++++++++++++++ tests/_pyodide/test_pyodide_session.py | 7 +++++++ tests/_server/api/endpoints/test_export.py | 14 ++++++++++++++ 10 files changed, 86 insertions(+), 14 deletions(-) diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 598bc6ecc65..073c7a707e6 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -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. " @@ -363,6 +365,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 +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. @@ -386,7 +399,7 @@ def md( return def export_callback(file_path: MarimoPath) -> ExportResult: - return export_as_md(file_path) + return export_as_md(file_path, flavor=flavor) return watch_and_export( MarimoPath(name), output, watch, export_callback, force diff --git a/marimo/_pyodide/pyodide_session.py b/marimo/_pyodide/pyodide_session.py index c056e217d98..f93bd58e8e4 100644 --- a/marimo/_pyodide/pyodide_session.py +++ b/marimo/_pyodide/pyodide_session.py @@ -36,7 +36,10 @@ from marimo._runtime.marimo_pdb import MarimoPdb from marimo._server.export.exporter import Exporter from marimo._server.files.os_file_system import OSFileSystem -from marimo._server.models.export import ExportAsHTMLRequest +from marimo._server.models.export import ( + ExportAsHTMLRequest, + ExportAsMarkdownRequest, +) from marimo._server.models.files import ( FileCopyRequest, FileCopyResponse, @@ -415,8 +418,10 @@ def export_html(self, request: str) -> str: return json.dumps(html) def export_markdown(self, request: str) -> str: - del request - md = convert_from_ir_to_markdown(self.session.app_manager.app.to_ir()) + parsed = self._parse(request, ExportAsMarkdownRequest) + md = convert_from_ir_to_markdown( + self.session.app_manager.app.to_ir(), flavor=parsed.flavor + ) return json.dumps(md) def _parse(self, request: str, cls: type[T]) -> T: diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 1500f79f70c..100f239e8a5 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -277,7 +277,9 @@ 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 = convert_from_ir_to_markdown( + app_file_manager.app.to_ir(), flavor=body.flavor + ) if body.download: download_filename = get_download_filename( @@ -382,6 +384,7 @@ async def auto_export_as_markdown( description: File must be saved before downloading """ app_state = AppState(request) + body = await parse_request(request, cls=ExportAsMarkdownRequest) session = app_state.require_current_session() session_view = session.session_view @@ -401,7 +404,7 @@ async def _background_export() -> None: session.app_file_manager.reload() markdown = convert_from_ir_to_markdown( - session.app_file_manager.app.to_ir() + session.app_file_manager.app.to_ir(), flavor=body.flavor ) # Save the Markdown file to disk, at `.marimo/<filename>.md` diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py index 18473723924..255b37d725d 100644 --- a/marimo/_server/export/__init__.py +++ b/marimo/_server/export/__init__.py @@ -51,6 +51,7 @@ if TYPE_CHECKING: from collections.abc import Mapping + from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._server.export._pdf_raster import PDFRasterizationOptions from marimo._server.export._status import PDFExportStatusCallback from marimo._session.state.session_view import SessionView @@ -102,10 +103,12 @@ def export_as_script(path: MarimoPath) -> ExportResult: ) -def export_as_md(path: MarimoPath) -> ExportResult: +def export_as_md( + path: MarimoPath, flavor: MarkdownFlavorName | None = None +) -> ExportResult: ir = _as_ir(path) return ExportResult( - contents=MarimoConvert.from_ir(ir).to_markdown(), + contents=MarimoConvert.from_ir(ir).to_markdown(flavor=flavor), download_filename=get_download_filename(path.short_name, "md"), did_error=False, ) diff --git a/marimo/_server/models/export.py b/marimo/_server/models/export.py index 97602848240..1b894487772 100644 --- a/marimo/_server/models/export.py +++ b/marimo/_server/models/export.py @@ -5,6 +5,7 @@ import msgspec +from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._messaging.mimetypes import MimeBundleTuple from marimo._types.ids import CellId_t @@ -25,7 +26,8 @@ class ExportAsIPYNBRequest(msgspec.Struct, rename="camel"): class ExportAsMarkdownRequest(msgspec.Struct, rename="camel"): - download: bool + download: bool = False + flavor: MarkdownFlavorName | None = None ExportPDFPreset = Literal["document", "slides"] diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 27dc721cb70..a3440bec65a 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -1620,9 +1620,17 @@ components: ExportAsMarkdownRequest: properties: download: + default: false type: boolean - required: - - download + flavor: + anyOf: + - enum: + - pymdown + - qmd + - mystmd + type: string + - type: 'null' + default: null title: ExportAsMarkdownRequest type: object ExportAsPDFRequest: @@ -7115,4 +7123,3 @@ paths: summary: Get the auth token for the current session tags: - auth - diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index e4b05c760a1..325c581a11c 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -4336,7 +4336,10 @@ export interface components { }; /** ExportAsMarkdownRequest */ ExportAsMarkdownRequest: { - download: boolean; + /** @default false */ + download?: boolean; + /** @default null */ + flavor?: "pymdown" | "qmd" | "mystmd" | null; }; /** ExportAsPDFRequest */ ExportAsPDFRequest: { diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index 632fe372d42..f829793d48d 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -702,6 +702,21 @@ 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_with_invalid_flavor( + temp_marimo_file: str, + ) -> None: + p = _run_export("md", temp_marimo_file, "--flavor", "unknown") + _assert_failure(p) + assert "invalid value for '--flavor'" in p.output + assert "'mystmd'" in p.output + @staticmethod def test_export_markdown_with_errors( temp_marimo_file_with_errors: str, diff --git a/tests/_pyodide/test_pyodide_session.py b/tests/_pyodide/test_pyodide_session.py index 7f5ec0197db..804c0156eeb 100644 --- a/tests/_pyodide/test_pyodide_session.py +++ b/tests/_pyodide/test_pyodide_session.py @@ -902,6 +902,13 @@ def test_pyodide_bridge_export_markdown( assert isinstance(markdown, str) assert len(markdown) > 0 + result = pyodide_bridge.export_markdown( + json.dumps({"download": False, "flavor": "qmd"}) + ) + markdown = json.loads(result) + + assert "```{marimo .python" in markdown + async def test_pyodide_bridge_read_snippets( pyodide_bridge: PyodideBridge, diff --git a/tests/_server/api/endpoints/test_export.py b/tests/_server/api/endpoints/test_export.py index 2fb2310c194..50dff3bd5c7 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -213,6 +213,20 @@ def test_export_markdown(client: TestClient) -> None: ) +@with_session(SESSION_ID) +def test_export_markdown_with_flavor(client: TestClient) -> None: + response = client.post( + "/api/export/markdown", + headers=HEADERS, + json={ + "download": False, + "flavor": "qmd", + }, + ) + assert response.status_code == 200 + assert "```{marimo .python" in response.text + + @pytest.mark.skipif( not DependencyManager.nbformat.has(), reason="nbformat not installed" ) From 8304c68a1d7b68126001c0bdffc447c9aaae0360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Mon, 18 May 2026 20:54:35 +0200 Subject: [PATCH 03/20] fix: normalize qmd callout titles --- marimo/_convert/markdown/flavor/qmd.py | 14 +++++++++++-- .../markdown/test_markdown_from_ir.py | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py index 8febce4802f..553d0531ff1 100644 --- a/marimo/_convert/markdown/flavor/qmd.py +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -20,6 +20,7 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +from html import unescape from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar @@ -144,7 +145,7 @@ def render_directive(self, block: DirectiveBlock) -> str: attributes = [ f".callout-{callout_type}", *( - [f'title="{_escape_attribute(block.argument)}"'] + [f'title="{_escape_attribute(_clean_title(block.argument))}"'] if block.argument else [] ), @@ -196,6 +197,13 @@ def _escape_attribute(value: str) -> str: return value.replace("&", "&").replace('"', """) +def _clean_title(value: str) -> str: + value = unescape(value).strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + def _render_pandoc_div(block: DirectiveBlock) -> str: attributes = _pandoc_attributes(block) return "\n".join( @@ -211,7 +219,9 @@ def _render_pandoc_div(block: DirectiveBlock) -> str: def _pandoc_attributes(block: DirectiveBlock) -> list[str]: attributes = [f".{block.name}"] if block.argument: - attributes.append(f'title="{_escape_attribute(block.argument)}"') + attributes.append( + f'title="{_escape_attribute(_clean_title(block.argument))}"' + ) for key, value in block.options.items(): if key == "attrs" and isinstance(value, dict): diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index d0d84d08009..29ea74a212a 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -429,6 +429,27 @@ def test_qmd_flavor_maps_pymdown_admonitions_to_callouts(): assert ":::\n\nAfter" in markdown +def test_qmd_flavor_unquotes_pymdown_callout_title(): + """Test that quoted PyMdown callout titles become plain QMD titles.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// tip | "Variables panel" + +Open the variables panel. +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert '::: {.callout-tip title="Variables panel"}' in markdown + + def test_qmd_flavor_maps_generic_admonition_type_to_callout(): """Test that generic PyMdown admonition type selects Quarto callout type.""" flavor = markdown_flavor_from_filename("notebook.qmd") From 9953a070471f881fdc727c9003238cde3f147f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Mon, 18 May 2026 21:11:55 +0200 Subject: [PATCH 04/20] fix: preserve explicit qmd callout title quotes --- marimo/_convert/markdown/flavor/qmd.py | 14 ++-------- .../markdown/test_markdown_from_ir.py | 27 ++++++++++++++++--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py index 553d0531ff1..8febce4802f 100644 --- a/marimo/_convert/markdown/flavor/qmd.py +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -20,7 +20,6 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -from html import unescape from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar @@ -145,7 +144,7 @@ def render_directive(self, block: DirectiveBlock) -> str: attributes = [ f".callout-{callout_type}", *( - [f'title="{_escape_attribute(_clean_title(block.argument))}"'] + [f'title="{_escape_attribute(block.argument)}"'] if block.argument else [] ), @@ -197,13 +196,6 @@ def _escape_attribute(value: str) -> str: return value.replace("&", "&").replace('"', """) -def _clean_title(value: str) -> str: - value = unescape(value).strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: - return value[1:-1] - return value - - def _render_pandoc_div(block: DirectiveBlock) -> str: attributes = _pandoc_attributes(block) return "\n".join( @@ -219,9 +211,7 @@ def _render_pandoc_div(block: DirectiveBlock) -> str: def _pandoc_attributes(block: DirectiveBlock) -> list[str]: attributes = [f".{block.name}"] if block.argument: - attributes.append( - f'title="{_escape_attribute(_clean_title(block.argument))}"' - ) + attributes.append(f'title="{_escape_attribute(block.argument)}"') for key, value in block.options.items(): if key == "attrs" and isinstance(value, dict): diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index 29ea74a212a..ac29a5fadc0 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -429,15 +429,15 @@ def test_qmd_flavor_maps_pymdown_admonitions_to_callouts(): assert ":::\n\nAfter" in markdown -def test_qmd_flavor_unquotes_pymdown_callout_title(): - """Test that quoted PyMdown callout titles become plain QMD titles.""" +def test_qmd_flavor_preserves_plain_pymdown_callout_title(): + """Test that plain PyMdown callout titles stay unquoted in QMD.""" flavor = markdown_flavor_from_filename("notebook.qmd") document = MarkdownExportDocument( metadata={"title": "Notebook"}, header=None, blocks=[ MarkdownCellBlock( - """/// tip | "Variables panel" + """/// tip | Variables panel Open the variables panel. ///""" @@ -450,6 +450,27 @@ def test_qmd_flavor_unquotes_pymdown_callout_title(): assert '::: {.callout-tip title="Variables panel"}' in markdown +def test_qmd_flavor_preserves_quoted_pymdown_callout_title(): + """Test that quoted PyMdown callout titles stay quoted in QMD.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + MarkdownCellBlock( + """/// tip | "Variables panel" + +Open the variables panel. +///""" + ) + ], + ) + + markdown = flavor.render_document(document) + + assert '::: {.callout-tip title=""Variables panel""}' in markdown + + def test_qmd_flavor_maps_generic_admonition_type_to_callout(): """Test that generic PyMdown admonition type selects Quarto callout type.""" flavor = markdown_flavor_from_filename("notebook.qmd") From 02b423580b5db60faa3e8b9e0a7d63d1d0c9c5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Mon, 18 May 2026 21:36:04 +0200 Subject: [PATCH 05/20] fix: harden flavored markdown rendering --- marimo/_convert/markdown/flavor/pymdown.py | 18 +++++- marimo/_convert/markdown/flavor/qmd.py | 12 +++- .../markdown/test_markdown_from_ir.py | 62 +++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/marimo/_convert/markdown/flavor/pymdown.py b/marimo/_convert/markdown/flavor/pymdown.py index 086cd667097..8057f09d0a2 100644 --- a/marimo/_convert/markdown/flavor/pymdown.py +++ b/marimo/_convert/markdown/flavor/pymdown.py @@ -157,6 +157,7 @@ def render_pymdown_tab_set(block: TabSetBlock) -> str: ) _PYMDOWN_BLOCK_END_RE = re.compile(r"^ {0,3}(?P<fence>/{3,})[ \t]*$") _MARKDOWN_FENCE_RE = re.compile(r"^ {0,3}(?P<fence>`{3,}|~{3,})") +_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^ {0,3}(?P<fence>`{3,}|~{3,})[ \t]*$") def split_pymdown_blocks(text: str) -> list[MarkdownExportBlock]: @@ -174,7 +175,7 @@ def split_pymdown_blocks(text: str) -> list[MarkdownExportBlock]: fence = fence_match.group("fence") if markdown_fence is None: markdown_fence = fence - elif line.strip().startswith(markdown_fence): + elif _is_markdown_fence_close(line, markdown_fence): markdown_fence = None pending.append(line) index += 1 @@ -261,6 +262,16 @@ def _find_pymdown_block_end( return None +def _is_markdown_fence_close(line: str, opening_fence: str) -> bool: + match = _MARKDOWN_FENCE_CLOSE_RE.match(line) + if match is None: + return False + closing_fence = match.group("fence") + return closing_fence[0] == opening_fence[0] and len(closing_fence) >= len( + opening_fence + ) + + def _append_markdown_block( blocks: list[MarkdownExportBlock], lines: list[str] ) -> None: @@ -281,8 +292,11 @@ def _extract_pymdown_options(lines: list[str]) -> tuple[dict[str, Any], str]: option_lines.append(line[4:]) body_start += 1 continue - if not line.strip() and option_lines: + if option_lines and not line.strip(): body_start += 1 + break + if option_lines: + return {}, "\n".join(lines).strip("\n") break if option_lines: diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py index 8febce4802f..3f1d67aeb23 100644 --- a/marimo/_convert/markdown/flavor/qmd.py +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -41,6 +41,8 @@ if TYPE_CHECKING: from collections.abc import Mapping +_MARIMO_QMD_FILTER = "marimo-team/marimo" + class QmdMarkdownFlavor(MarkdownFlavor): """Render marimo exports as Quarto-native markdown. @@ -81,7 +83,9 @@ def transform_blocks( def prepare_metadata( self, metadata: dict[str, str | list[str]] ) -> dict[str, str | list[str]]: - return metadata.copy() + qmd_metadata = metadata.copy() + qmd_metadata.setdefault("filters", [_MARIMO_QMD_FILTER]) + return qmd_metadata def render_preamble(self, document: MarkdownExportDocument) -> list[str]: from marimo._utils import yaml @@ -121,7 +125,11 @@ def _render_code_fence( attributes = dict(attributes or {}) language = attributes.pop("language", "python") attribute_str = " ".join( - [""] + [f'{key}="{value}"' for key, value in attributes.items()] + [""] + + [ + f'{key}="{_escape_attribute(str(value))}"' + for key, value in attributes.items() + ] ) guard = "```" while guard in code: diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index ac29a5fadc0..3e9373476db 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -8,9 +8,11 @@ ) from marimo._convert.markdown.flavor.base import ( CodeCellBlock, + DirectiveBlock, MarkdownCellBlock, MarkdownExportDocument, ) +from marimo._convert.markdown.flavor.pymdown import split_pymdown_blocks from marimo._convert.markdown.from_ir import ( _format_filename_title, _get_sql_options_from_cell, @@ -382,9 +384,31 @@ def test_qmd_flavor_renders_export_document(): markdown = flavor.render_document(document) + assert "filters:\n- marimo-team/marimo" in markdown assert "```{marimo .python}" in markdown +def test_qmd_flavor_escapes_code_cell_attributes(): + """Test that qmd code fence attributes escape quotes and ampersands.""" + flavor = markdown_flavor_from_filename("notebook.qmd") + document = MarkdownExportDocument( + metadata={"title": "Notebook"}, + header=None, + blocks=[ + CodeCellBlock( + "x = 1", + "python", + {"name": 'a"b & c', "engine": 'duck&"db'}, + ) + ], + ) + + markdown = flavor.render_document(document) + + assert 'name="a"b & c"' in markdown + assert 'engine="duck&"db"' in markdown + + def test_qmd_flavor_preserves_explicit_filters(): """Test that qmd flavor serializes user-provided filters.""" flavor = markdown_flavor_from_filename("notebook.qmd") @@ -629,6 +653,44 @@ def test_qmd_flavor_falls_back_to_pandoc_divs(): assert "Body\n:::" in markdown +def test_split_pymdown_blocks_keeps_directives_inside_markdown_fences(): + """Test that literal directives inside code fences are not parsed.""" + blocks = split_pymdown_blocks( + """```text +literal +``` not a closing fence +/// tip | Should stay code +body +/// +``` + +/// tip | Real +body +///""" + ) + + assert len(blocks) == 2 + assert isinstance(blocks[0], MarkdownCellBlock) + assert "/// tip | Should stay code" in blocks[0].text + assert isinstance(blocks[1], DirectiveBlock) + assert blocks[1].argument == "Real" + + +def test_split_pymdown_blocks_preserves_body_colon_lines(): + """Test that body-leading colon lines are not consumed as options.""" + blocks = split_pymdown_blocks( + """/// details | Example + key: value + still body +///""" + ) + + assert len(blocks) == 1 + assert isinstance(blocks[0], DirectiveBlock) + assert blocks[0].options == {} + assert blocks[0].body == " key: value\n still body" + + def test_mystmd_flavor_maps_pymdown_blocks_to_myst_directives(): """Test that mystmd flavor maps PyMdown blocks to MyST directives.""" flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") From e77862f147238917e9d8d51430e09c97079ffec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Mon, 18 May 2026 21:36:09 +0200 Subject: [PATCH 06/20] fix: invalidate markdown auto-export by flavor --- marimo/_server/api/endpoints/export.py | 12 +++++++++--- marimo/_session/state/session_view.py | 16 ++++++++++++++-- packages/openapi/api.yaml | 4 ++-- packages/openapi/src/api.ts | 2 +- tests/_server/api/endpoints/test_export.py | 20 ++++++++++++++++++-- tests/_session/state/test_session_view.py | 4 ++++ 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 100f239e8a5..5413c395034 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -19,6 +19,7 @@ make_download_headers, ) from marimo._convert.markdown import convert_from_ir_to_markdown +from marimo._convert.markdown.flavor import 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 @@ -394,8 +395,13 @@ async def auto_export_as_markdown( detail="File must have a name before exporting", ) - # If we have already exported to Markdown, don't do it again - if not session_view.needs_export("md"): + markdown_flavor = normalize_markdown_flavor( + body.flavor, filename="notebook.md" + ).name + + # If we have already exported to Markdown with this flavor, don't do it + # again. + if not session_view.needs_md_export(markdown_flavor): LOGGER.debug("Already auto-exported to Markdown") return PlainTextResponse(status_code=HTTPStatus.NOT_MODIFIED) @@ -412,7 +418,7 @@ async def _background_export() -> None: filename=session.app_file_manager.filename, markdown=markdown, ) - session_view.mark_auto_export_md() + session_view.mark_auto_export_md(markdown_flavor) return JSONResponse( content=asdict(SuccessResponse()), diff --git a/marimo/_session/state/session_view.py b/marimo/_session/state/session_view.py index 805ef69ca38..9804f1497bb 100644 --- a/marimo/_session/state/session_view.py +++ b/marimo/_session/state/session_view.py @@ -114,12 +114,14 @@ def to_notification(self) -> ModelLifecycleNotification: class AutoExportState: html: bool = False md: bool = False + md_flavor: str | None = None ipynb: bool = False session: bool = False def mark_all_stale(self) -> None: self.html = False self.md = False + self.md_flavor = None self.ipynb = False self.session = False @@ -129,6 +131,13 @@ def is_stale(self, export_type: ExportType) -> bool: def mark_exported(self, export_type: ExportType) -> None: setattr(self, export_type, True) + def is_md_stale(self, flavor: str) -> bool: + return not self.md or self.md_flavor != flavor + + def mark_md_exported(self, flavor: str) -> None: + self.md = True + self.md_flavor = flavor + class SessionView: """A representation of a session state for replay and serialization. @@ -581,8 +590,8 @@ def is_empty(self) -> bool: def mark_auto_export_html(self) -> None: self.auto_export_state.mark_exported("html") - def mark_auto_export_md(self) -> None: - self.auto_export_state.mark_exported("md") + def mark_auto_export_md(self, flavor: str = "pymdown") -> None: + self.auto_export_state.mark_md_exported(flavor) def mark_auto_export_ipynb(self) -> None: self.auto_export_state.mark_exported("ipynb") @@ -593,6 +602,9 @@ def mark_auto_export_session(self) -> None: def needs_export(self, export_type: ExportType) -> bool: return self.auto_export_state.is_stale(export_type) + def needs_md_export(self, flavor: str = "pymdown") -> bool: + return self.auto_export_state.is_md_stale(flavor) + def _touch(self) -> None: self.auto_export_state.mark_all_stale() diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index a3440bec65a..87f8e783150 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -1625,12 +1625,12 @@ components: flavor: anyOf: - enum: + - mystmd - pymdown - qmd - - mystmd - type: string - type: 'null' default: null + required: [] title: ExportAsMarkdownRequest type: object ExportAsPDFRequest: diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 325c581a11c..bca94e3152e 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -4339,7 +4339,7 @@ export interface components { /** @default false */ download?: boolean; /** @default null */ - flavor?: "pymdown" | "qmd" | "mystmd" | null; + flavor?: ("mystmd" | "pymdown" | "qmd") | null; }; /** ExportAsPDFRequest */ ExportAsPDFRequest: { diff --git a/tests/_server/api/endpoints/test_export.py b/tests/_server/api/endpoints/test_export.py index 50dff3bd5c7..ead8593127c 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -410,15 +410,31 @@ def test_auto_export_markdown( headers=HEADERS, json={ "download": False, + "flavor": "qmd", + }, + ) + assert response.status_code == 200 + assert response.json() == {"success": True} + + response = client.post( + "/api/export/auto_export/markdown", + headers=HEADERS, + json={ + "download": False, + "flavor": "qmd", }, ) # Not modified response assert response.status_code == 304 # Assert __marimo__ file is created - assert os.path.exists( - os.path.join(os.path.dirname(temp_marimo_file), "__marimo__") + exported_file = os.path.join( + os.path.dirname(temp_marimo_file), + "__marimo__", + f"{Path(temp_marimo_file).stem}.md", ) + assert os.path.exists(exported_file) + assert "```{marimo .python" in Path(exported_file).read_text() @pytest.mark.skipif( diff --git a/tests/_session/state/test_session_view.py b/tests/_session/state/test_session_view.py index d23b9b8b3cb..2ae2cd9a70c 100644 --- a/tests/_session/state/test_session_view.py +++ b/tests/_session/state/test_session_view.py @@ -1318,16 +1318,20 @@ def test_get_cell_console_outputs( def test_mark_auto_export(session_view: SessionView): assert session_view.needs_export("html") assert session_view.needs_export("md") + assert session_view.needs_md_export("pymdown") session_view.mark_auto_export_html() assert not session_view.needs_export("html") session_view.mark_auto_export_md() assert not session_view.needs_export("md") + assert not session_view.needs_md_export("pymdown") + assert session_view.needs_md_export("qmd") session_view._touch() assert session_view.needs_export("html") assert session_view.needs_export("md") + assert session_view.needs_md_export("pymdown") session_view.mark_auto_export_html() session_view.mark_auto_export_md() From baaf3dbf9920e5a92f534e3f95db2b2ca38f65fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Mon, 18 May 2026 22:19:49 +0200 Subject: [PATCH 07/20] fix: remove obsolete markdown flavor defaults --- marimo/_convert/markdown/flavor/qmd.py | 6 +----- marimo/_convert/markdown/to_ir.py | 16 ++++++++++++---- .../markdown/test_markdown_conversion.py | 16 +++++++++++++++- tests/_convert/markdown/test_markdown_from_ir.py | 2 +- tests/_server/files/test_os_file_system.py | 2 +- tests/_server/test_file_manager.py | 2 -- 6 files changed, 30 insertions(+), 14 deletions(-) diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py index 3f1d67aeb23..a639c64038e 100644 --- a/marimo/_convert/markdown/flavor/qmd.py +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -41,8 +41,6 @@ if TYPE_CHECKING: from collections.abc import Mapping -_MARIMO_QMD_FILTER = "marimo-team/marimo" - class QmdMarkdownFlavor(MarkdownFlavor): """Render marimo exports as Quarto-native markdown. @@ -83,9 +81,7 @@ def transform_blocks( def prepare_metadata( self, metadata: dict[str, str | list[str]] ) -> dict[str, str | list[str]]: - qmd_metadata = metadata.copy() - qmd_metadata.setdefault("filters", [_MARIMO_QMD_FILTER]) - return qmd_metadata + return metadata.copy() def render_preamble(self, document: MarkdownExportDocument) -> list[str]: from marimo._utils import yaml diff --git a/marimo/_convert/markdown/to_ir.py b/marimo/_convert/markdown/to_ir.py index fa241e64411..4e276314423 100644 --- a/marimo/_convert/markdown/to_ir.py +++ b/marimo/_convert/markdown/to_ir.py @@ -320,6 +320,7 @@ def __init__( self, *args: Any, output_format: ConvertKeys = "marimo-ir", + enable_myst: bool = False, **kwargs: Any, ) -> None: super().__init__( @@ -336,9 +337,10 @@ def __init__( self.preprocessors.register( FrontMatterPreprocessor(self), "frontmatter", 100 ) - self.preprocessors.register( - MystMarimoPreprocessor(self), "myst-marimo", 99 - ) + if enable_myst: + self.preprocessors.register( + MystMarimoPreprocessor(self), "myst-marimo", 99 + ) fences_ext = SuperFencesCodeExtension() fences_ext.extendMarkdown(self) # TODO: Consider adding the admonition extension, and integrating it @@ -577,7 +579,13 @@ 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", + enable_myst=any( + _is_myst_marimo_directive_header(line) + for line in text.splitlines() + ), + ).convert(text) assert isinstance(notebook, NotebookSerializationV1) return NotebookSerializationV1( app=notebook.app, diff --git a/tests/_convert/markdown/test_markdown_conversion.py b/tests/_convert/markdown/test_markdown_conversion.py index 7e106f41a61..387279ef7c1 100644 --- a/tests/_convert/markdown/test_markdown_conversion.py +++ b/tests/_convert/markdown/test_markdown_conversion.py @@ -12,7 +12,10 @@ 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.to_ir import ( + MarimoMdParser, + convert_from_md_to_marimo_ir, +) # Just a handful of scripts to test from marimo._tutorials import dataflow, for_jupyter_users, sql @@ -165,6 +168,17 @@ def test_mystmd_marimo_directives() -> None: assert "result" in app.cell_manager.cell_data_at(ids[2]).code +def test_mystmd_preprocessor_registers_conditionally() -> None: + plain_parser = MarimoMdParser(output_format="marimo-ir") + myst_parser = MarimoMdParser( + output_format="marimo-ir", + enable_myst=True, + ) + + assert "myst-marimo" not in plain_parser.preprocessors + assert "myst-marimo" in myst_parser.preprocessors + + 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 3e9373476db..43f5e7d7578 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -384,7 +384,7 @@ def test_qmd_flavor_renders_export_document(): markdown = flavor.render_document(document) - assert "filters:\n- marimo-team/marimo" in markdown + assert "filters:" not in markdown assert "```{marimo .python}" in markdown diff --git a/tests/_server/files/test_os_file_system.py b/tests/_server/files/test_os_file_system.py index 543fe908933..a1b4d34b16d 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-version:"), ], ) def test_create_notebook( diff --git a/tests/_server/test_file_manager.py b/tests/_server/test_file_manager.py index b836921cd4a..28dd89d2d6d 100644 --- a/tests/_server/test_file_manager.py +++ b/tests/_server/test_file_manager.py @@ -162,8 +162,6 @@ 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 "app = marimo.App()" not in contents From f4249c02118f7aea6f9747825a73ba4a47ce89b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 09:16:22 +0200 Subject: [PATCH 08/20] feat: infer flavor from filename --- marimo/_cli/export/commands.py | 38 +++++++++++++++++++++++++++- marimo/_server/export/__init__.py | 8 ++++-- tests/_cli/test_cli_export.py | 42 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 073c7a707e6..1f58d3531d2 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -171,6 +171,40 @@ async def start() -> None: asyncio_run(start()) +def _markdown_output_filename(output: Path | None) -> str | None: + if output is not None: + return str(output) + + stdout_path = _redirected_stdout_path() + return str(stdout_path) if stdout_path is not None else None + + +def _redirected_stdout_path() -> Path | None: + if sys.stdout.isatty(): + return None + + for fd_path in (Path("/proc/self/fd/1"), Path("/dev/fd/1")): + try: + target = os.readlink(fd_path) + except OSError: + continue + path = Path(target) + if path.is_absolute() and path.suffix: + return path + + try: + import fcntl + + path_bytes = fcntl.fcntl( + sys.stdout.fileno(), fcntl.F_GETPATH, b"\0" * 1024 + ) + except (AttributeError, OSError): + return None + + path = Path(path_bytes.split(b"\0", 1)[0].decode()) + return path if path.is_absolute() and path.suffix else None + + @click.command( cls=ColoredCommand, help="""Run a notebook and export it as an HTML file. @@ -398,8 +432,10 @@ def md( run_in_sandbox(sys.argv[1:], name=name) return + filename = _markdown_output_filename(output) + def export_callback(file_path: MarimoPath) -> ExportResult: - return export_as_md(file_path, flavor=flavor) + return export_as_md(file_path, flavor=flavor, filename=filename) return watch_and_export( MarimoPath(name), output, watch, export_callback, force diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py index 255b37d725d..008d01a9788 100644 --- a/marimo/_server/export/__init__.py +++ b/marimo/_server/export/__init__.py @@ -104,11 +104,15 @@ def export_as_script(path: MarimoPath) -> ExportResult: def export_as_md( - path: MarimoPath, flavor: MarkdownFlavorName | None = None + path: MarimoPath, + flavor: MarkdownFlavorName | None = None, + filename: str | None = None, ) -> ExportResult: ir = _as_ir(path) return ExportResult( - contents=MarimoConvert.from_ir(ir).to_markdown(flavor=flavor), + contents=MarimoConvert.from_ir(ir).to_markdown( + filename=filename, flavor=flavor + ), download_filename=get_download_filename(path.short_name, "md"), did_error=False, ) diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index f829793d48d..e9a7cd5103f 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -708,6 +708,48 @@ def test_export_markdown_with_flavor(temp_marimo_file: str) -> None: _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 / "notebook.qmd" + + 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_infers_qmd_from_redirected_stdout( + temp_marimo_file: str, tmp_path: Path + ) -> None: + with mock.patch( + "marimo._cli.export.commands._redirected_stdout_path", + return_value=tmp_path / "notebook.qmd", + ): + p = _run_export("md", temp_marimo_file) + + _assert_success(p) + assert "```{marimo .python" in p.output + + @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_invalid_flavor( temp_marimo_file: str, From 574456d240bae94762905e71817de056893cef77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 09:57:22 +0200 Subject: [PATCH 09/20] fix: preserve authored markdown in flavor exports --- marimo/_convert/markdown/flavor/__init__.py | 6 +- marimo/_convert/markdown/flavor/base.py | 76 +--- marimo/_convert/markdown/flavor/myst.py | 272 --------------- marimo/_convert/markdown/flavor/mystmd.py | 165 +++++++++ marimo/_convert/markdown/flavor/pymdown.py | 232 +------------ marimo/_convert/markdown/flavor/qmd.py | 173 +--------- .../markdown/test_markdown_from_ir.py | 324 ------------------ 7 files changed, 184 insertions(+), 1064 deletions(-) delete mode 100644 marimo/_convert/markdown/flavor/myst.py create mode 100644 marimo/_convert/markdown/flavor/mystmd.py diff --git a/marimo/_convert/markdown/flavor/__init__.py b/marimo/_convert/markdown/flavor/__init__.py index 85bb8e00556..659d0ca0bc0 100644 --- a/marimo/_convert/markdown/flavor/__init__.py +++ b/marimo/_convert/markdown/flavor/__init__.py @@ -9,7 +9,7 @@ MarkdownFlavor, MarkdownFlavorName, ) -from marimo._convert.markdown.flavor.myst import MystMarkdownFlavor +from marimo._convert.markdown.flavor.mystmd import MystmdMarkdownFlavor from marimo._convert.markdown.flavor.pymdown import PymdownMarkdownFlavor from marimo._convert.markdown.flavor.qmd import QmdMarkdownFlavor @@ -18,13 +18,13 @@ _PYMDOWN_MARKDOWN = PymdownMarkdownFlavor() _QMD_MARKDOWN = QmdMarkdownFlavor() -_MYST_MARKDOWN = MystMarkdownFlavor() +_MYSTMD_MARKDOWN = MystmdMarkdownFlavor() _MARKDOWN_FLAVORS: Mapping[MarkdownFlavorName, MarkdownFlavor] = ( MappingProxyType( { _PYMDOWN_MARKDOWN.name: _PYMDOWN_MARKDOWN, _QMD_MARKDOWN.name: _QMD_MARKDOWN, - _MYST_MARKDOWN.name: _MYST_MARKDOWN, + _MYSTMD_MARKDOWN.name: _MYSTMD_MARKDOWN, } ) ) diff --git a/marimo/_convert/markdown/flavor/base.py b/marimo/_convert/markdown/flavor/base.py index b556012dd47..f3df0ccf6c5 100644 --- a/marimo/_convert/markdown/flavor/base.py +++ b/marimo/_convert/markdown/flavor/base.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, ClassVar, Literal if TYPE_CHECKING: from collections.abc import Iterator @@ -23,22 +23,7 @@ class CodeCellBlock: options: dict[str, str] -@dataclass(frozen=True) -class DirectiveBlock: - name: str - argument: str | None - options: dict[str, Any] - body: str - - -@dataclass(frozen=True) -class TabSetBlock: - tabs: list[DirectiveBlock] - - -MarkdownExportBlock = ( - MarkdownCellBlock | CodeCellBlock | DirectiveBlock | TabSetBlock -) +MarkdownExportBlock = MarkdownCellBlock | CodeCellBlock @dataclass(frozen=True) @@ -51,9 +36,9 @@ class MarkdownExportDocument: class MarkdownFlavor(ABC): """Markdown-family output flavor. - This class defines document assembly: metadata, markdown text, code cells, - directives, and tab groups are delegated to concrete flavors. That keeps - dialect-specific syntax out of the template method. + This class defines document assembly while keeping target-specific + metadata, markdown text, and code cell syntax delegated to concrete + flavors. """ name: ClassVar[MarkdownFlavorName] @@ -69,10 +54,10 @@ def prepare_metadata( """ def render_document(self, document: MarkdownExportDocument) -> str: - """Render a document by applying block transforms, then block renderers. + """Render a document by applying flavor-specific block renderers. Consecutive markdown cells are separated by an HTML comment, while - transitions from markdown to executable/directive blocks get a blank + transitions from markdown to executable code blocks get a blank line. Flavors should override smaller render hooks before replacing this whole assembly step. """ @@ -80,7 +65,7 @@ def render_document(self, document: MarkdownExportDocument) -> str: def render_blocks() -> Iterator[str]: previous_was_markdown = False - for block in self.transform_blocks(document.blocks): + for block in document.blocks: if isinstance(block, MarkdownCellBlock): if previous_was_markdown: yield "<!---->" @@ -92,12 +77,7 @@ def render_blocks() -> Iterator[str]: yield "" previous_was_markdown = False - if isinstance(block, CodeCellBlock): - yield self.render_code_cell(block) - elif isinstance(block, TabSetBlock): - yield self.render_tab_set(block) - else: - yield self.render_directive(block) + yield self.render_code_cell(block) return "\n".join( [ @@ -117,25 +97,7 @@ def render_preamble(self, document: MarkdownExportDocument) -> list[str]: @abstractmethod def render_markdown(self, block: MarkdownCellBlock) -> str: - """Render markdown text that already belongs to the target flavor. - - Cross-flavor conversion of embedded syntax belongs in - `transform_blocks`, not here, so markdown cells can first be split into - more specific block objects. For example, a target flavor may parse - source callouts or tab blocks before deciding how to render them. - """ - - @abstractmethod - def transform_blocks( - self, blocks: list[MarkdownExportBlock] - ) -> list[MarkdownExportBlock]: - """Normalize the block stream before rendering. - - Target flavors use this hook to parse source-specific blocks out of - markdown cells and group related blocks. For example, a flavor may - split PyMdown-style `///` blocks out of markdown text, then group - consecutive tab directives before rendering. - """ + """Render markdown text without altering user-authored markdown.""" @abstractmethod def render_code_cell(self, cell: CodeCellBlock) -> str: @@ -145,21 +107,3 @@ def render_code_cell(self, cell: CodeCellBlock) -> str: attributes, some use directive option lines, and others may use a different executable-cell wrapper. """ - - @abstractmethod - def render_directive(self, block: DirectiveBlock) -> str: - """Render a semantic directive block. - - Concrete flavors decide the target-native form. For example, a semantic - callout might become a PyMdown `///` block, a Quarto callout, a MyST - directive, or another container syntax. - """ - - @abstractmethod - def render_tab_set(self, block: TabSetBlock) -> str: - """Render a grouped tab container. - - Tabs are grouped before rendering so target flavors can emit one - container when the target supports one. Examples include Quarto panel - tabsets, MyST tab-set directives, and PyMdown tab blocks. - """ diff --git a/marimo/_convert/markdown/flavor/myst.py b/marimo/_convert/markdown/flavor/myst.py deleted file mode 100644 index dd14317b827..00000000000 --- a/marimo/_convert/markdown/flavor/myst.py +++ /dev/null @@ -1,272 +0,0 @@ -"""MyST target flavor for marimo notebook exports. - -Marimo cells are emitted as MyST directives: - -```{marimo} python -:hide-code: true - -x = 1 -``` - -Syntax references: -- MyST directives, callouts, admonitions, and dropdown admonitions: - https://mystmd.org/guide/admonitions -- MyST tab-set and tab-item directives: - https://mystmd.org/docs/mystjs/dropdowns-cards-and-tabs -""" - -# Copyright 2026 Marimo. All rights reserved. -from __future__ import annotations - -import re -from typing import TYPE_CHECKING - -from marimo._convert.markdown.flavor.base import ( - CodeCellBlock, - DirectiveBlock, - MarkdownCellBlock, - MarkdownExportBlock, - MarkdownExportDocument, - MarkdownFlavor, - TabSetBlock, -) -from marimo._convert.markdown.flavor.pymdown import ( - group_pymdown_tabs, - option_is_truthy, - split_pymdown_blocks, -) - -if TYPE_CHECKING: - from collections.abc import Mapping - -_MARIMO_VERSION_KEY = "marimo-version" -_CONFIG_KEYS = {"header", "pyproject"} -_MARIMO_METADATA_KEYS = {_MARIMO_VERSION_KEY, "width"} -_SCRIPT_METADATA_RE = re.compile( - r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s" - r"(?P<content>(^#(| .*)$\s)+)^# ///$" -) - - -class MystMarkdownFlavor(MarkdownFlavor): - """Render marimo notebooks as MyST markdown. - - MyST is directive-oriented, so this flavor emits marimo cells with body - option lines instead of inline fence attributes. 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 transform_blocks( - self, blocks: list[MarkdownExportBlock] - ) -> list[MarkdownExportBlock]: - transformed: list[MarkdownExportBlock] = [] - for block in blocks: - if isinstance(block, MarkdownCellBlock): - transformed.extend(split_pymdown_blocks(block.text)) - else: - transformed.append(block) - return group_pymdown_tabs(transformed) - - def render_preamble(self, document: MarkdownExportDocument) -> list[str]: - metadata = self.prepare_metadata(document.metadata) - return [ - *_myst_frontmatter(metadata), - *_myst_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() - if not any(line.strip() for line in code_lines): - code_lines = [ - "pass" if cell.language == "python" else "-- empty cell" - ] - code = "\n".join(code_lines) - guard = "```" - while guard in code: - guard += "`" - - return "\n".join( - [ - f"{guard}{{marimo}} {cell.language}", - *[ - f":{_myst_option_name(key)}: {value}" - for key, value in cell.options.items() - ], - *([""] if cell.options else []), - code, - guard, - "", - ] - ) - - def render_directive(self, block: DirectiveBlock) -> str: - """Render PyMdown-style directives as native MyST directives. - - MyST admonition and dropdown syntax: - https://mystmd.org/guide/admonitions - """ - name = "dropdown" if block.name == "details" else block.name - head = f":::{{{name}}}" - if block.argument is not None: - head = f"{head} {block.argument}" - - options = _myst_options(block) - return "\n".join( - [ - head, - *([options, ""] if options else []), - block.body, - ":::", - "", - ] - ) - - def render_tab_set(self, block: TabSetBlock) -> str: - """Render PyMdown tabs as MyST's native tab directives. - - MyST tab-set and tab-item syntax: - https://mystmd.org/docs/mystjs/dropdowns-cards-and-tabs - """ - return "\n".join( - [ - "::::{tab-set}", - *[ - line - for tab in block.tabs - for line in [ - f":::{{tab-item}} {tab.argument or 'Tab'}", - *( - [":selected:"] - if option_is_truthy(tab.options.get("select")) - else [] - ), - tab.body, - ":::", - ] - ], - "::::", - "", - ] - ) - - -def _myst_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 _myst_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() - config = { - key: value - for key, value in {"header": header, "pyproject": pyproject}.items() - if value - } - if not config: - return [] - - body = yaml.marimo_compat_dump(config, sort_keys=False).strip() - return [ - "```{marimo-config}", - "---", - body, - "---", - "```", - "", - ] - - -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 _myst_options(block: DirectiveBlock) -> str: - admonition_type = ( - block.options.get("type") if block.name == "admonition" else None - ) - details_type = ( - block.options.get("type") if block.name == "details" else None - ) - attrs = block.options.get("attrs") - block_id = attrs.get("id") if isinstance(attrs, dict) else None - classes = attrs.get("class") if isinstance(attrs, dict) else None - class_names = [ - str(class_name) - for class_name in [admonition_type, details_type] - if class_name - ] - if classes: - class_names.extend(str(classes).split()) - - return "\n".join( - [ - *([f":class: {' '.join(class_names)}"] if class_names else []), - *( - [":open:"] - if block.name == "details" - and option_is_truthy(block.options.get("open")) - else [] - ), - *([f":label: {block_id}"] if block_id else []), - ] - ) - - -def _myst_option_name(key: str) -> str: - return key.replace("_", "-") diff --git a/marimo/_convert/markdown/flavor/mystmd.py b/marimo/_convert/markdown/flavor/mystmd.py new file mode 100644 index 00000000000..3eec4aec34a --- /dev/null +++ b/marimo/_convert/markdown/flavor/mystmd.py @@ -0,0 +1,165 @@ +"""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._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownCellBlock, + MarkdownExportDocument, + MarkdownFlavor, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +_MARIMO_VERSION_KEY = "marimo-version" +_CONFIG_KEYS = {"header", "pyproject"} +_MARIMO_METADATA_KEYS = {_MARIMO_VERSION_KEY, "width"} +_SCRIPT_METADATA_RE = re.compile( + r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s" + r"(?P<content>(^#(| .*)$\s)+)^# ///$" +) + + +class MystmdMarkdownFlavor(MarkdownFlavor): + """Render marimo notebooks as mystmd markdown. + + mystmd is directive-oriented, so this flavor emits marimo cells with body + option lines instead of inline fence attributes. 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() + if not any(line.strip() for line in code_lines): + code_lines = [ + "pass" if cell.language == "python" else "-- empty cell" + ] + code = "\n".join(code_lines) + guard = "```" + while guard in code: + guard += "`" + + 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() + config = { + key: value + for key, value in {"header": header, "pyproject": pyproject}.items() + if value + } + if not config: + return [] + + body = yaml.marimo_compat_dump(config, sort_keys=False).strip() + return [ + "```{marimo-config}", + "---", + body, + "---", + "```", + "", + ] + + +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("_", "-") diff --git a/marimo/_convert/markdown/flavor/pymdown.py b/marimo/_convert/markdown/flavor/pymdown.py index 8057f09d0a2..fcf6ed8b5fb 100644 --- a/marimo/_convert/markdown/flavor/pymdown.py +++ b/marimo/_convert/markdown/flavor/pymdown.py @@ -1,46 +1,13 @@ -"""PyMdown Blocks markdown flavor. - -Example source block: - -/// tip | Heads up - attrs: {id: tip-demo} - -Body -/// - -Syntax references: -- Blocks fences and options: - https://facelessuser.github.io/pymdown-extensions/extensions/blocks/ -- Blocks admonitions: - https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition/ -- Blocks details: - https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/details/ -- Blocks tabs: - https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/tab/ -- Legacy tabbed extension: - https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/ -- Obsidian-style quotes/callouts: - https://facelessuser.github.io/pymdown-extensions/extensions/quotes/ -""" +"""PyMdown markdown target flavor.""" # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -import re -from typing import Any - -from pymdownx.blocks import ( # type: ignore[import-untyped] - get_frontmatter as _get_pymdown_frontmatter, -) - from marimo._convert.markdown.flavor.base import ( CodeCellBlock, - DirectiveBlock, MarkdownCellBlock, - MarkdownExportBlock, MarkdownExportDocument, MarkdownFlavor, - TabSetBlock, ) from marimo._dependencies.dependencies import DependencyManager @@ -48,8 +15,8 @@ class PymdownMarkdownFlavor(MarkdownFlavor): """Render marimo Markdown with PyMdown syntax. - This flavor emits YAML frontmatter, fenced marimo code cells, and PyMdown - Blocks for admonitions, details, and tabs. + This flavor emits YAML frontmatter and fenced marimo code cells while + preserving markdown cells as authored. """ name = "pymdown" @@ -76,23 +43,12 @@ def render_preamble(self, document: MarkdownExportDocument) -> list[str]: def render_markdown(self, block: MarkdownCellBlock) -> str: return block.text - def transform_blocks( - self, blocks: list[MarkdownExportBlock] - ) -> list[MarkdownExportBlock]: - return blocks - def render_code_cell(self, cell: CodeCellBlock) -> str: return self._render_code_fence( cell.source, {"language": cell.language, **cell.options}, ) - def render_directive(self, block: DirectiveBlock) -> str: - return render_pymdown_directive(block) - - def render_tab_set(self, block: TabSetBlock) -> str: - return render_pymdown_tab_set(block) - def _code_fence_head( self, guard: str, language: str, attribute_str: str ) -> str: @@ -121,185 +77,3 @@ def _render_code_fence( head = self._code_fence_head(guard, language, attribute_str) parts = [head, code, guard, ""] return "\n".join(parts) - - -def render_pymdown_directive(block: DirectiveBlock) -> str: - """Render a directive using PyMdown Blocks syntax. - - Target flavors can use this helper when a directive has no more specific - target-native rendering. - """ - options = "\n".join( - f"{key}: {value}" for key, value in block.options.items() - ) - head = f"/// {block.name}" - if block.argument is not None: - head = f"{head} | {block.argument}" - - return "\n".join( - [ - head, - *([f" {options}", ""] if options else [""]), - block.body, - "///", - ] - ) - - -def render_pymdown_tab_set(block: TabSetBlock) -> str: - """Expand grouped tabs back to consecutive PyMdown tab directives.""" - return "\n\n".join(render_pymdown_directive(tab) for tab in block.tabs) - - -_PYMDOWN_BLOCK_START_RE = re.compile( - r"^(?P<indent> {0,3})(?P<fence>/{3,})[ \t]+" - r"(?P<name>[\w-]+)[ \t]*(?:\|[ \t]*(?P<title>.*?)[ \t]*)?$" -) -_PYMDOWN_BLOCK_END_RE = re.compile(r"^ {0,3}(?P<fence>/{3,})[ \t]*$") -_MARKDOWN_FENCE_RE = re.compile(r"^ {0,3}(?P<fence>`{3,}|~{3,})") -_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^ {0,3}(?P<fence>`{3,}|~{3,})[ \t]*$") - - -def split_pymdown_blocks(text: str) -> list[MarkdownExportBlock]: - """Split PyMdown `/// name | argument` blocks out of plain markdown.""" - lines = text.splitlines() - blocks: list[MarkdownExportBlock] = [] - pending: list[str] = [] - index = 0 - markdown_fence: str | None = None - - while index < len(lines): - line = lines[index] - fence_match = _MARKDOWN_FENCE_RE.match(line) - if fence_match is not None: - fence = fence_match.group("fence") - if markdown_fence is None: - markdown_fence = fence - elif _is_markdown_fence_close(line, markdown_fence): - markdown_fence = None - pending.append(line) - index += 1 - continue - - start = ( - None - if markdown_fence is not None - else _PYMDOWN_BLOCK_START_RE.match(line) - ) - if start is None: - pending.append(line) - index += 1 - continue - - end_index = _find_pymdown_block_end( - lines, index + 1, start.group("fence") - ) - if end_index is None: - pending.append(line) - index += 1 - continue - - _append_markdown_block(blocks, pending) - body_lines = lines[index + 1 : end_index] - options, body = _extract_pymdown_options(body_lines) - blocks.append( - DirectiveBlock( - name=start.group("name").lower(), - argument=start.group("title") or None, - options=options, - body=body, - ) - ) - index = end_index + 1 - - _append_markdown_block(blocks, pending) - return blocks - - -def group_pymdown_tabs( - blocks: list[MarkdownExportBlock], -) -> list[MarkdownExportBlock]: - """Group consecutive `/// tab` blocks like PyMdown Blocks does.""" - grouped: list[MarkdownExportBlock] = [] - pending_tabs: list[DirectiveBlock] = [] - - def flush_tabs() -> None: - if pending_tabs: - grouped.append(TabSetBlock(pending_tabs.copy())) - pending_tabs.clear() - - for block in blocks: - if isinstance(block, DirectiveBlock) and block.name == "tab": - if option_is_truthy(block.options.get("new")): - flush_tabs() - pending_tabs.append(block) - continue - - flush_tabs() - grouped.append(block) - - flush_tabs() - return grouped - - -def option_is_truthy(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.lower() in {"true", "1", "yes", "on"} - return bool(value) - - -def _find_pymdown_block_end( - lines: list[str], start_index: int, opening_fence: str -) -> int | None: - for index in range(start_index, len(lines)): - match = _PYMDOWN_BLOCK_END_RE.match(lines[index]) - if match is not None and len(match.group("fence")) == len( - opening_fence - ): - return index - return None - - -def _is_markdown_fence_close(line: str, opening_fence: str) -> bool: - match = _MARKDOWN_FENCE_CLOSE_RE.match(line) - if match is None: - return False - closing_fence = match.group("fence") - return closing_fence[0] == opening_fence[0] and len(closing_fence) >= len( - opening_fence - ) - - -def _append_markdown_block( - blocks: list[MarkdownExportBlock], lines: list[str] -) -> None: - text = "\n".join(lines).strip("\n") - lines.clear() - if text: - blocks.append(MarkdownCellBlock(text)) - - -def _extract_pymdown_options(lines: list[str]) -> tuple[dict[str, Any], str]: - options: dict[str, Any] = {} - body_start = 0 - option_lines: list[str] = [] - - while body_start < len(lines): - line = lines[body_start] - if line.startswith(" ") and ":" in line: - option_lines.append(line[4:]) - body_start += 1 - continue - if option_lines and not line.strip(): - body_start += 1 - break - if option_lines: - return {}, "\n".join(lines).strip("\n") - break - - if option_lines: - options = _get_pymdown_frontmatter("\n".join(option_lines)) or {} - - return options, "\n".join(lines[body_start:]).strip("\n") diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py index a639c64038e..f9bdeb882e7 100644 --- a/marimo/_convert/markdown/flavor/qmd.py +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -1,83 +1,25 @@ -"""Quarto markdown target flavor. - -PyMdown admonitions become Quarto callouts: - -/// tip | Heads up -Body -/// - -::: {.callout-tip title="Heads up"} -Body -::: - -Syntax references: -- Quarto callout blocks: - https://quarto.org/docs/authoring/callouts.html -- Quarto tabset panels: - https://quarto.org/docs/interactive/layout.html#tabset-panel -""" +"""Quarto markdown target flavor.""" # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -from types import MappingProxyType -from typing import TYPE_CHECKING, ClassVar - from marimo._convert.markdown.flavor.base import ( CodeCellBlock, - DirectiveBlock, MarkdownCellBlock, - MarkdownExportBlock, MarkdownExportDocument, MarkdownFlavor, - TabSetBlock, -) -from marimo._convert.markdown.flavor.pymdown import ( - group_pymdown_tabs, - option_is_truthy, - split_pymdown_blocks, ) -if TYPE_CHECKING: - from collections.abc import Mapping - class QmdMarkdownFlavor(MarkdownFlavor): """Render marimo exports as Quarto-native markdown. - This flavor parses PyMdown Blocks from markdown cells and maps known - semantic blocks to Quarto callouts and panel tabsets. Unknown directives - are emitted as Pandoc fenced divs. + This flavor emits Quarto-style executable marimo code fences while + preserving markdown cells as authored. """ name = "qmd" - _callout_types: ClassVar[Mapping[str, str]] = MappingProxyType( - { - "admonition": "note", - "attention": "important", - "caution": "caution", - "danger": "caution", - "error": "caution", - "hint": "tip", - "important": "important", - "note": "note", - "tip": "tip", - "warning": "warning", - } - ) - - def transform_blocks( - self, blocks: list[MarkdownExportBlock] - ) -> list[MarkdownExportBlock]: - transformed: list[MarkdownExportBlock] = [] - for block in blocks: - if isinstance(block, MarkdownCellBlock): - transformed.extend(split_pymdown_blocks(block.text)) - else: - transformed.append(block) - return group_pymdown_tabs(transformed) - def prepare_metadata( self, metadata: dict[str, str | list[str]] ) -> dict[str, str | list[str]]: @@ -135,115 +77,6 @@ def _render_code_fence( parts = [head, code, guard, ""] return "\n".join(parts) - def render_directive(self, block: DirectiveBlock) -> str: - """Render PyMdown admonitions as Quarto callout blocks. - - Quarto callout syntax: - https://quarto.org/docs/authoring/callouts.html - """ - callout_type = self._callout_type(block) - if callout_type is None: - return _render_pandoc_div(block) - - attributes = [ - f".callout-{callout_type}", - *( - [f'title="{_escape_attribute(block.argument)}"'] - if block.argument - else [] - ), - *[ - f'{key}="{_escape_attribute(str(value))}"' - for key in ("collapse", "appearance", "icon") - for value in [block.options.get(key)] - if value is not None - ], - ] - - return "\n".join( - [ - f"::: {{{' '.join(attributes)}}}", - block.body, - ":::", - "", - ] - ) - - def render_tab_set(self, block: TabSetBlock) -> str: - """Render PyMdown tabs as a Quarto `.panel-tabset` div. - - Quarto tabset panel syntax: - https://quarto.org/docs/interactive/layout.html#tabset-panel - """ - return "\n".join( - [ - "::: {.panel-tabset}", - *[ - line - for tab in block.tabs - for line in ["", _tab_heading(tab), "", tab.body] - ], - ":::", - "", - ] - ) - - def _callout_type(self, block: DirectiveBlock) -> str | None: - if block.name == "admonition": - explicit_type = block.options.get("type") - if isinstance(explicit_type, str): - return self._callout_types.get(explicit_type) - return self._callout_types.get(block.name) - def _escape_attribute(value: str) -> str: return value.replace("&", "&").replace('"', """) - - -def _render_pandoc_div(block: DirectiveBlock) -> str: - attributes = _pandoc_attributes(block) - return "\n".join( - [ - f"::: {{{' '.join(attributes)}}}", - block.body, - ":::", - "", - ] - ) - - -def _pandoc_attributes(block: DirectiveBlock) -> list[str]: - attributes = [f".{block.name}"] - if block.argument: - attributes.append(f'title="{_escape_attribute(block.argument)}"') - - for key, value in block.options.items(): - if key == "attrs" and isinstance(value, dict): - block_id = value.get("id") - if block_id: - attributes.append(f"#{block_id}") - classes = value.get("class") - if classes: - attributes.extend( - f".{class_name}" - for class_name in str(classes).split() - if class_name - ) - continue - attributes.append( - f'{key}="{_escape_attribute(_attribute_value(value))}"' - ) - return attributes - - -def _attribute_value(value: object) -> str: - if isinstance(value, bool): - return str(value).lower() - return str(value) - - -def _tab_heading(tab: DirectiveBlock) -> str: - heading = f"## {tab.argument or 'Tab'}" - if option_is_truthy(tab.options.get("select")): - return f"{heading} {{.active}}" - return heading diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index 43f5e7d7578..dba4e14bd15 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -8,11 +8,9 @@ ) from marimo._convert.markdown.flavor.base import ( CodeCellBlock, - DirectiveBlock, MarkdownCellBlock, MarkdownExportDocument, ) -from marimo._convert.markdown.flavor.pymdown import split_pymdown_blocks from marimo._convert.markdown.from_ir import ( _format_filename_title, _get_sql_options_from_cell, @@ -425,303 +423,6 @@ def test_qmd_flavor_preserves_explicit_filters(): ) -def test_qmd_flavor_maps_pymdown_admonitions_to_callouts(): - """Test that qmd flavor renders PyMdown admonitions as Quarto callouts.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """Before - -/// attention | Careful - -This needs attention. -/// - -After""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert '::: {.callout-important title="Careful"}' in markdown - assert "This needs attention.\n:::" in markdown - assert "Before\n\n::: {.callout-important" in markdown - assert ":::\n\nAfter" in markdown - - -def test_qmd_flavor_preserves_plain_pymdown_callout_title(): - """Test that plain PyMdown callout titles stay unquoted in QMD.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// tip | Variables panel - -Open the variables panel. -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert '::: {.callout-tip title="Variables panel"}' in markdown - - -def test_qmd_flavor_preserves_quoted_pymdown_callout_title(): - """Test that quoted PyMdown callout titles stay quoted in QMD.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// tip | "Variables panel" - -Open the variables panel. -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert '::: {.callout-tip title=""Variables panel""}' in markdown - - -def test_qmd_flavor_maps_generic_admonition_type_to_callout(): - """Test that generic PyMdown admonition type selects Quarto callout type.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// admonition | Heads up - type: warning - -Watch this. -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert '::: {.callout-warning title="Heads up"}' in markdown - assert "Watch this.\n:::" in markdown - - -def test_pymdown_flavor_preserves_pymdown_admonitions(): - """Test that pymdown flavor keeps PyMdown syntax unchanged.""" - flavor = markdown_flavor_from_filename("notebook.md") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// tip | Keep this - -PyMdown syntax is preserved. -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert "/// tip | Keep this" in markdown - - -def test_convert_from_ir_to_markdown_maps_admonitions_for_qmd(): - """Test full export maps PyMdown markdown admonitions for qmd output.""" - app = App() - - @app.cell() - def __(): - import marimo as mo - - return (mo,) - - @app.cell() - def __(mo): - mo.md( - """ - /// tip | Tip with Title - - This is an example. - /// - """ - ) - return - - internal_app = InternalApp(app) - notebook = internal_app.to_ir() - - markdown = convert_from_ir_to_markdown(notebook, filename="notebook.qmd") - - assert '::: {.callout-tip title="Tip with Title"}' in markdown - assert "This is an example.\n:::" in markdown - - -def test_qmd_flavor_maps_pymdown_tabs_to_panel_tabsets(): - """Test that consecutive PyMdown tabs become a Quarto panel tabset.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// tab | Python -print("py") -/// - -/// tab | SQL - select: true - -select 1 -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert "::: {.panel-tabset}" in markdown - assert '## Python\n\nprint("py")' in markdown - assert "## SQL {.active}\n\nselect 1" in markdown - - -def test_qmd_flavor_starts_new_tabset_for_pymdown_new_tab_option(): - """Test that PyMdown tab new option starts another Quarto tabset.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// tab | A -A -/// - -/// tab | B - new: true - -B -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert markdown.count("::: {.panel-tabset}") == 2 - assert "## A\n\nA" in markdown - assert "## B\n\nB" in markdown - - -def test_qmd_flavor_falls_back_to_pandoc_divs(): - """Test that unmapped PyMdown directives become Quarto-compatible divs.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// details | More - attrs: {id: more, class: folded quiet} - open: true - -Body -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert ( - '::: {.details title="More" #more .folded .quiet open="true"}' - in markdown - ) - assert "Body\n:::" in markdown - - -def test_split_pymdown_blocks_keeps_directives_inside_markdown_fences(): - """Test that literal directives inside code fences are not parsed.""" - blocks = split_pymdown_blocks( - """```text -literal -``` not a closing fence -/// tip | Should stay code -body -/// -``` - -/// tip | Real -body -///""" - ) - - assert len(blocks) == 2 - assert isinstance(blocks[0], MarkdownCellBlock) - assert "/// tip | Should stay code" in blocks[0].text - assert isinstance(blocks[1], DirectiveBlock) - assert blocks[1].argument == "Real" - - -def test_split_pymdown_blocks_preserves_body_colon_lines(): - """Test that body-leading colon lines are not consumed as options.""" - blocks = split_pymdown_blocks( - """/// details | Example - key: value - still body -///""" - ) - - assert len(blocks) == 1 - assert isinstance(blocks[0], DirectiveBlock) - assert blocks[0].options == {} - assert blocks[0].body == " key: value\n still body" - - -def test_mystmd_flavor_maps_pymdown_blocks_to_myst_directives(): - """Test that mystmd flavor maps PyMdown blocks to MyST directives.""" - flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// tip | Nice -Body -/// - -/// tab | Python -print("py") -/// - -/// tab | SQL -select 1 -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert ":::{tip} Nice\nBody\n:::" in markdown - assert "::::{tab-set}" in markdown - assert ':::{tab-item} Python\nprint("py")\n:::' in markdown - assert ":::{tab-item} SQL\nselect 1\n:::" in markdown - - def test_mystmd_flavor_renders_marimo_notebook_export_syntax(): """Test that mystmd flavor renders marimo notebook authoring syntax.""" flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") @@ -787,28 +488,3 @@ def test_mystmd_flavor_grows_code_fence_guard(): assert "````{marimo} python" in markdown assert markdown.rstrip().endswith("````") - - -def test_mystmd_flavor_merges_classes_into_one_option(): - """Test that MyST directives do not repeat the class option.""" - flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock( - """/// admonition | Heads up - type: tip - attrs: {class: extra} - -Body -///""" - ) - ], - ) - - markdown = flavor.render_document(document) - - assert ( - ":::{admonition} Heads up\n:class: tip extra\n\nBody\n:::" in markdown - ) From 5a7996610c927c162a25bbed86ba6c916ffb6f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 09:57:33 +0200 Subject: [PATCH 10/20] refactor: use mystmd naming for markdown import --- docs/guides/exporting/markdown.md | 2 +- marimo/_convert/markdown/to_ir.py | 53 +++++++++++-------- .../markdown/test_markdown_conversion.py | 8 +-- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/docs/guides/exporting/markdown.md b/docs/guides/exporting/markdown.md index 2c704a40543..4c01705170e 100644 --- a/docs/guides/exporting/markdown.md +++ b/docs/guides/exporting/markdown.md @@ -27,7 +27,7 @@ order as they appear in the notebook. marimo export md notebook.py -o notebook.md ``` -This can be useful to plug into other tools that read markdown, such as [Quarto](https://quarto.org/) or [MyST](https://myst-parser.readthedocs.io/). +This can be useful to plug into other tools that read markdown, such as [Quarto](https://quarto.org/) or [mystmd](https://mystmd.org/). !!! tip "marimo can open markdown files as notebooks" Learn more with `marimo tutorial markdown-format` at the command line. diff --git a/marimo/_convert/markdown/to_ir.py b/marimo/_convert/markdown/to_ir.py index 4e276314423..24a6b2cd28d 100644 --- a/marimo/_convert/markdown/to_ir.py +++ b/marimo/_convert/markdown/to_ir.py @@ -53,10 +53,21 @@ MARIMO_MD = "marimo-md" MARIMO_CODE = "marimo-code" -_MYST_MARIMO_HEADER_RE = re.compile( +# mystmd directives are fenced code blocks with the directive name in braces, +# followed by directive arguments and optional `:key: value` option lines. +# Reference: https://mystmd.org/guide/directives +# +# marimo code blocks use the mystmd directive form: +# +# ```{marimo} python +# :hide-code: true +# +# print("hello") +# ``` +_MYSTMD_MARIMO_HEADER_RE = re.compile( r"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$" ) -_MYST_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$") +_MYSTMD_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$") ConvertKeys = Literal["marimo-ir"] @@ -83,18 +94,18 @@ def extract_attribs( return {} -def _is_myst_marimo_directive_header(line: str) -> bool: - return bool(re.match(r"^`{3,}\{marimo\}(?:\s+\w+)?\s*$", line)) +def _is_mystmd_marimo_directive_header(line: str) -> bool: + return bool(_MYSTMD_MARIMO_HEADER_RE.match(line)) -def _extract_myst_directive_options( +def _extract_mystmd_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 = _MYST_DIRECTIVE_OPTION_RE.match(line) + match = _MYSTMD_DIRECTIVE_OPTION_RE.match(line) if match is None: break options[match.group(1).replace("-", "_")] = match.group(2) or "true" @@ -118,9 +129,9 @@ def _is_code_tag(text: str) -> bool: def _get_language(text: str) -> str: header = text.split("\n").pop(0) - myst_match = re.match(r"^`{3,}\{marimo\}\s+(?P<language>\w+)", header) - if myst_match: - return str(myst_match.group("language")) + mystmd_match = re.match(r"^`{3,}\{marimo\}\s+(?P<language>\w+)", header) + if mystmd_match: + return str(mystmd_match.group("language")) match = RE_NESTED_FENCE_START.match(header) if match and match.group("lang"): return str(match.group("lang")) @@ -320,7 +331,7 @@ def __init__( self, *args: Any, output_format: ConvertKeys = "marimo-ir", - enable_myst: bool = False, + enable_mystmd: bool = False, **kwargs: Any, ) -> None: super().__init__( @@ -337,9 +348,9 @@ def __init__( self.preprocessors.register( FrontMatterPreprocessor(self), "frontmatter", 100 ) - if enable_myst: + if enable_mystmd: self.preprocessors.register( - MystMarimoPreprocessor(self), "myst-marimo", 99 + MystmdMarimoPreprocessor(self), "mystmd-marimo", 99 ) fences_ext = SuperFencesCodeExtension() fences_ext.extendMarkdown(self) @@ -406,15 +417,15 @@ def run(self, lines: list[str]) -> list[str]: return doc.split("\n") -class MystMarimoPreprocessor(Preprocessor): - """Normalize MyST marimo directive fences before SuperFences parses them.""" +class MystmdMarimoPreprocessor(Preprocessor): + """Normalize mystmd marimo directive fences before SuperFences parses them.""" def run(self, lines: list[str]) -> list[str]: normalized: list[str] = [] index = 0 while index < len(lines): - match = _MYST_MARIMO_HEADER_RE.match(lines[index]) + match = _MYSTMD_MARIMO_HEADER_RE.match(lines[index]) if match is None: normalized.append(lines[index]) index += 1 @@ -423,7 +434,7 @@ def run(self, lines: list[str]) -> list[str]: index += 1 options: dict[str, str] = {} while index < len(lines): - option = _MYST_DIRECTIVE_OPTION_RE.match(lines[index]) + option = _MYSTMD_DIRECTIVE_OPTION_RE.match(lines[index]) if option is None: break options[option.group(1).replace("-", "_")] = ( @@ -555,11 +566,11 @@ def add_paragraph() -> None: body_lines = block_lines[1:-1] attribs = extract_attribs(block_lines[0]) - if _is_myst_marimo_directive_header(block_lines[0]): - myst_options, body_lines = _extract_myst_directive_options( + if _is_mystmd_marimo_directive_header(block_lines[0]): + mystmd_options, body_lines = _extract_mystmd_directive_options( body_lines ) - attribs.update(myst_options) + attribs.update(mystmd_options) code_block.text = "\n".join(body_lines) if attribs: code_block.attrib = attribs @@ -581,8 +592,8 @@ def convert_from_md_to_marimo_ir( ) notebook = MarimoMdParser( output_format="marimo-ir", - enable_myst=any( - _is_myst_marimo_directive_header(line) + enable_mystmd=any( + _is_mystmd_marimo_directive_header(line) for line in text.splitlines() ), ).convert(text) diff --git a/tests/_convert/markdown/test_markdown_conversion.py b/tests/_convert/markdown/test_markdown_conversion.py index 387279ef7c1..f80e573b27b 100644 --- a/tests/_convert/markdown/test_markdown_conversion.py +++ b/tests/_convert/markdown/test_markdown_conversion.py @@ -170,13 +170,13 @@ def test_mystmd_marimo_directives() -> None: def test_mystmd_preprocessor_registers_conditionally() -> None: plain_parser = MarimoMdParser(output_format="marimo-ir") - myst_parser = MarimoMdParser( + mystmd_parser = MarimoMdParser( output_format="marimo-ir", - enable_myst=True, + enable_mystmd=True, ) - assert "myst-marimo" not in plain_parser.preprocessors - assert "myst-marimo" in myst_parser.preprocessors + assert "mystmd-marimo" not in plain_parser.preprocessors + assert "mystmd-marimo" in mystmd_parser.preprocessors def test_no_frontmatter() -> None: From a1cd8233dd3f0f654412e051202b5d53d6726c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 10:16:48 +0200 Subject: [PATCH 11/20] docs: cleanup --- docs/guides/exporting/markdown.md | 2 +- marimo/_tutorials/markdown_format.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/exporting/markdown.md b/docs/guides/exporting/markdown.md index 4c01705170e..2c704a40543 100644 --- a/docs/guides/exporting/markdown.md +++ b/docs/guides/exporting/markdown.md @@ -27,7 +27,7 @@ order as they appear in the notebook. marimo export md notebook.py -o notebook.md ``` -This can be useful to plug into other tools that read markdown, such as [Quarto](https://quarto.org/) or [mystmd](https://mystmd.org/). +This can be useful to plug into other tools that read markdown, such as [Quarto](https://quarto.org/) or [MyST](https://myst-parser.readthedocs.io/). !!! tip "marimo can open markdown files as notebooks" Learn more with `marimo tutorial markdown-format` at the command line. diff --git a/marimo/_tutorials/markdown_format.md b/marimo/_tutorials/markdown_format.md index f284051d1f8..c7c675fa5e4 100644 --- a/marimo/_tutorials/markdown_format.md +++ b/marimo/_tutorials/markdown_format.md @@ -276,4 +276,4 @@ more information on how to typeset and render markdown in marimo. ```python {.marimo hide_code="true"} import marimo as mo -``` +``` \ No newline at end of file From 345f701263708b61e61f170547797521edc308fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 10:35:14 +0200 Subject: [PATCH 12/20] fix: keep markdown output inference explicit --- marimo/_cli/export/commands.py | 36 +--------------------------------- tests/_cli/test_cli_export.py | 13 ------------ 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 1f58d3531d2..103d7665aed 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -171,40 +171,6 @@ async def start() -> None: asyncio_run(start()) -def _markdown_output_filename(output: Path | None) -> str | None: - if output is not None: - return str(output) - - stdout_path = _redirected_stdout_path() - return str(stdout_path) if stdout_path is not None else None - - -def _redirected_stdout_path() -> Path | None: - if sys.stdout.isatty(): - return None - - for fd_path in (Path("/proc/self/fd/1"), Path("/dev/fd/1")): - try: - target = os.readlink(fd_path) - except OSError: - continue - path = Path(target) - if path.is_absolute() and path.suffix: - return path - - try: - import fcntl - - path_bytes = fcntl.fcntl( - sys.stdout.fileno(), fcntl.F_GETPATH, b"\0" * 1024 - ) - except (AttributeError, OSError): - return None - - path = Path(path_bytes.split(b"\0", 1)[0].decode()) - return path if path.is_absolute() and path.suffix else None - - @click.command( cls=ColoredCommand, help="""Run a notebook and export it as an HTML file. @@ -432,7 +398,7 @@ def md( run_in_sandbox(sys.argv[1:], name=name) return - filename = _markdown_output_filename(output) + filename = str(output) if output is not None else None def export_callback(file_path: MarimoPath) -> ExportResult: return export_as_md(file_path, flavor=flavor, filename=filename) diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index e9a7cd5103f..d120b2a51b2 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -719,19 +719,6 @@ def test_export_markdown_infers_qmd_from_output( _assert_success(p) assert "```{marimo .python" in output.read_text() - @staticmethod - def test_export_markdown_infers_qmd_from_redirected_stdout( - temp_marimo_file: str, tmp_path: Path - ) -> None: - with mock.patch( - "marimo._cli.export.commands._redirected_stdout_path", - return_value=tmp_path / "notebook.qmd", - ): - p = _run_export("md", temp_marimo_file) - - _assert_success(p) - assert "```{marimo .python" in p.output - @staticmethod def test_export_markdown_explicit_flavor_overrides_output( temp_marimo_file: str, tmp_path: Path From a55b73762b3f006bb4c1b99296a6db9d1a4cd29c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 10:36:32 +0200 Subject: [PATCH 13/20] style: format code --- marimo/_server/api/endpoints/export.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 5413c395034..940662b56d3 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -399,8 +399,7 @@ async def auto_export_as_markdown( body.flavor, filename="notebook.md" ).name - # If we have already exported to Markdown with this flavor, don't do it - # again. + # If we have already exported to Markdown with this flavor, don't do it again. if not session_view.needs_md_export(markdown_flavor): LOGGER.debug("Already auto-exported to Markdown") return PlainTextResponse(status_code=HTTPStatus.NOT_MODIFIED) From aa5b1d15cbb352c662b81b879b7d962245a23e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 10:59:10 +0200 Subject: [PATCH 14/20] fix: cover escape edge case --- marimo/_convert/markdown/to_ir.py | 31 +++++++------------ .../markdown/test_markdown_conversion.py | 5 +++ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/marimo/_convert/markdown/to_ir.py b/marimo/_convert/markdown/to_ir.py index 24a6b2cd28d..9910bbb6b37 100644 --- a/marimo/_convert/markdown/to_ir.py +++ b/marimo/_convert/markdown/to_ir.py @@ -68,6 +68,7 @@ r"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$" ) _MYSTMD_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$") +_MYSTMD_DIRECTIVE_CLASS = "mystmd-marimo" ConvertKeys = Literal["marimo-ir"] @@ -98,6 +99,10 @@ def _is_mystmd_marimo_directive_header(line: str) -> bool: return bool(_MYSTMD_MARIMO_HEADER_RE.match(line)) +def _is_preprocessed_mystmd_marimo_fence(line: str) -> bool: + return f".{_MYSTMD_DIRECTIVE_CLASS}" in line + + def _extract_mystmd_directive_options( lines: list[str], ) -> tuple[dict[str, str], list[str]]: @@ -431,30 +436,14 @@ def run(self, lines: list[str]) -> list[str]: index += 1 continue - index += 1 - options: dict[str, str] = {} - while index < len(lines): - option = _MYSTMD_DIRECTIVE_OPTION_RE.match(lines[index]) - if option is None: - break - options[option.group(1).replace("-", "_")] = ( - option.group(2) or "true" - ) - index += 1 - - if options and index < len(lines) and lines[index] == "": - index += 1 - - attributes = "".join( - f' {key}="{value}"' for key, value in options.items() - ) normalized.append( - "{fence}{language} {{.marimo{attributes}}}".format( + "{fence}{language} {{.marimo .{directive_class}}}".format( fence=match.group("fence"), language=match.group("language") or "python", - attributes=attributes, + directive_class=_MYSTMD_DIRECTIVE_CLASS, ) ) + index += 1 return normalized @@ -566,7 +555,9 @@ def add_paragraph() -> None: body_lines = block_lines[1:-1] attribs = extract_attribs(block_lines[0]) - if _is_mystmd_marimo_directive_header(block_lines[0]): + if _is_mystmd_marimo_directive_header( + block_lines[0] + ) or _is_preprocessed_mystmd_marimo_fence(block_lines[0]): mystmd_options, body_lines = _extract_mystmd_directive_options( body_lines ) diff --git a/tests/_convert/markdown/test_markdown_conversion.py b/tests/_convert/markdown/test_markdown_conversion.py index f80e573b27b..784759ac3f8 100644 --- a/tests/_convert/markdown/test_markdown_conversion.py +++ b/tests/_convert/markdown/test_markdown_conversion.py @@ -141,6 +141,7 @@ def test_mystmd_marimo_directives() -> None: ```{marimo} sql :query: result + :engine: engines["primary"] SELECT 1 ``` @@ -166,6 +167,10 @@ def test_mystmd_marimo_directives() -> None: ) 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_preprocessor_registers_conditionally() -> None: From f9c54e2a7d8ddcf50b4d3e49773dd9f86ec08d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 12:23:55 +0200 Subject: [PATCH 15/20] fix: preserve flavored markdown export artifacts --- marimo/_convert/markdown/flavor/__init__.py | 53 ++++- marimo/_convert/markdown/flavor/mystmd.py | 3 +- marimo/_server/api/endpoints/export.py | 34 ++- marimo/_server/export/__init__.py | 14 +- marimo/_server/export/exporter.py | 21 +- marimo/_session/state/session_view.py | 21 +- tests/_cli/test_cli_export.py | 11 + .../markdown/test_markdown_from_ir.py | 9 +- tests/_server/api/endpoints/test_export.py | 206 +++++++++++++++++- tests/_server/export/test_export_markdown.py | 103 +++++++++ tests/_session/state/test_session_view.py | 6 + 11 files changed, 445 insertions(+), 36 deletions(-) create mode 100644 tests/_server/export/test_export_markdown.py diff --git a/marimo/_convert/markdown/flavor/__init__.py b/marimo/_convert/markdown/flavor/__init__.py index 659d0ca0bc0..8ea0da417ce 100644 --- a/marimo/_convert/markdown/flavor/__init__.py +++ b/marimo/_convert/markdown/flavor/__init__.py @@ -1,6 +1,7 @@ # 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 @@ -29,7 +30,22 @@ ) ) _MARKDOWN_FLAVORS_BY_EXTENSION: Mapping[str, MarkdownFlavor] = ( - MappingProxyType({".qmd": _QMD_MARKDOWN}) + 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", ) @@ -39,9 +55,11 @@ def default_markdown_flavor() -> MarkdownFlavor: def markdown_flavor_from_filename(filename: str) -> MarkdownFlavor: """Infer the export flavor from a filename extension.""" - return _MARKDOWN_FLAVORS_BY_EXTENSION.get( - Path(filename).suffix, default_markdown_flavor() - ) + 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( @@ -60,8 +78,35 @@ def normalize_markdown_flavor( 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", ] diff --git a/marimo/_convert/markdown/flavor/mystmd.py b/marimo/_convert/markdown/flavor/mystmd.py index 3eec4aec34a..a04a6133b62 100644 --- a/marimo/_convert/markdown/flavor/mystmd.py +++ b/marimo/_convert/markdown/flavor/mystmd.py @@ -25,9 +25,8 @@ if TYPE_CHECKING: from collections.abc import Mapping -_MARIMO_VERSION_KEY = "marimo-version" _CONFIG_KEYS = {"header", "pyproject"} -_MARIMO_METADATA_KEYS = {_MARIMO_VERSION_KEY, "width"} +_MARIMO_METADATA_KEYS = {"width"} _SCRIPT_METADATA_RE = re.compile( r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s" r"(?P<content>(^#(| .*)$\s)+)^# ///$" diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 940662b56d3..42e1f289bb1 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -19,7 +19,10 @@ make_download_headers, ) from marimo._convert.markdown import convert_from_ir_to_markdown -from marimo._convert.markdown.flavor import normalize_markdown_flavor +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 @@ -278,13 +281,17 @@ async def export_as_markdown( detail="File must be saved before downloading", ) + filename = app_file_manager.filename or "notebook.md" + markdown_flavor = normalize_markdown_flavor(body.flavor, filename=filename) markdown = convert_from_ir_to_markdown( - app_file_manager.app.to_ir(), flavor=body.flavor + app_file_manager.app.to_ir(), + filename=filename, + flavor=markdown_flavor, ) if body.download: - download_filename = get_download_filename( - app_file_manager.filename, "md" + download_filename = markdown_output_filename( + app_file_manager.filename, markdown_flavor ) headers = make_download_headers(download_filename) else: @@ -395,12 +402,11 @@ async def auto_export_as_markdown( detail="File must have a name before exporting", ) - markdown_flavor = normalize_markdown_flavor( - body.flavor, filename="notebook.md" - ).name + filename = session.app_file_manager.filename or "notebook.md" + markdown_flavor = normalize_markdown_flavor(body.flavor, filename=filename) # If we have already exported to Markdown with this flavor, don't do it again. - if not session_view.needs_md_export(markdown_flavor): + if not session_view.needs_md_export(markdown_flavor.name): LOGGER.debug("Already auto-exported to Markdown") return PlainTextResponse(status_code=HTTPStatus.NOT_MODIFIED) @@ -409,15 +415,21 @@ async def _background_export() -> None: session.app_file_manager.reload() markdown = convert_from_ir_to_markdown( - session.app_file_manager.app.to_ir(), flavor=body.flavor + session.app_file_manager.app.to_ir(), + filename=filename, + flavor=markdown_flavor, ) - # Save the Markdown file to disk, at `.marimo/<filename>.md` + # Save the Markdown file to disk, at `.marimo/<filename>.<extension>` await auto_exporter.save_md( filename=session.app_file_manager.filename, markdown=markdown, + download_filename=markdown_output_filename( + session.app_file_manager.filename, + markdown_flavor, + ), ) - session_view.mark_auto_export_md(markdown_flavor) + session_view.mark_auto_export_md(markdown_flavor.name) return JSONResponse( content=asdict(SuccessResponse()), diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py index 008d01a9788..52ef2dc77ed 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 ( @@ -109,11 +113,17 @@ def export_as_md( filename: str | None = None, ) -> ExportResult: ir = _as_ir(path) + export_filename = filename or ir.filename or path.short_name + markdown_flavor = normalize_markdown_flavor( + flavor, filename=export_filename + ) return ExportResult( contents=MarimoConvert.from_ir(ir).to_markdown( - filename=filename, flavor=flavor + filename=export_filename, flavor=markdown_flavor + ), + download_filename=markdown_output_filename( + export_filename, markdown_flavor ), - download_filename=get_download_filename(path.short_name, "md"), did_error=False, ) diff --git a/marimo/_server/export/exporter.py b/marimo/_server/export/exporter.py index cee285b552e..98c77724292 100644 --- a/marimo/_server/export/exporter.py +++ b/marimo/_server/export/exporter.py @@ -736,10 +736,16 @@ def __init__(self) -> None: ) async def _save_file( - self, filename: str | None, content: str, extension: str + self, + filename: str | None, + content: str, + extension: str, + download_filename: str | None = None, ) -> None: notebook_path = get_filename(filename) - download_name = get_download_filename(filename, extension) + download_name = download_filename or get_download_filename( + filename, extension + ) export_dir = notebook_output_dir(notebook_path) await self._ensure_export_dir_async(export_dir) @@ -754,8 +760,15 @@ async def _save_file( async def save_html(self, filename: str | None, html: str) -> None: await self._save_file(filename, html, "html") - async def save_md(self, filename: str | None, markdown: str) -> None: - await self._save_file(filename, markdown, "md") + async def save_md( + self, + filename: str | None, + markdown: str, + download_filename: str | None = None, + ) -> None: + await self._save_file( + filename, markdown, "md", download_filename=download_filename + ) async def save_ipynb(self, filename: str | None, ipynb: str) -> None: await self._save_file(filename, ipynb, "ipynb") diff --git a/marimo/_session/state/session_view.py b/marimo/_session/state/session_view.py index 9804f1497bb..b6c50842c10 100644 --- a/marimo/_session/state/session_view.py +++ b/marimo/_session/state/session_view.py @@ -2,10 +2,11 @@ from __future__ import annotations import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Literal, cast from marimo import _loggers +from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._data.models import DataSourceConnection, DataTable from marimo._messaging.cell_output import CellChannel, CellOutput from marimo._messaging.mimetypes import KnownMimeType, MimeBundleTuple @@ -114,14 +115,14 @@ def to_notification(self) -> ModelLifecycleNotification: class AutoExportState: html: bool = False md: bool = False - md_flavor: str | None = None + md_flavors: set[MarkdownFlavorName] = field(default_factory=set) ipynb: bool = False session: bool = False def mark_all_stale(self) -> None: self.html = False self.md = False - self.md_flavor = None + self.md_flavors.clear() self.ipynb = False self.session = False @@ -131,12 +132,12 @@ def is_stale(self, export_type: ExportType) -> bool: def mark_exported(self, export_type: ExportType) -> None: setattr(self, export_type, True) - def is_md_stale(self, flavor: str) -> bool: - return not self.md or self.md_flavor != flavor + def is_md_stale(self, flavor: MarkdownFlavorName) -> bool: + return flavor not in self.md_flavors - def mark_md_exported(self, flavor: str) -> None: + def mark_md_exported(self, flavor: MarkdownFlavorName) -> None: self.md = True - self.md_flavor = flavor + self.md_flavors.add(flavor) class SessionView: @@ -590,7 +591,9 @@ def is_empty(self) -> bool: def mark_auto_export_html(self) -> None: self.auto_export_state.mark_exported("html") - def mark_auto_export_md(self, flavor: str = "pymdown") -> None: + def mark_auto_export_md( + self, flavor: MarkdownFlavorName = "pymdown" + ) -> None: self.auto_export_state.mark_md_exported(flavor) def mark_auto_export_ipynb(self) -> None: @@ -602,7 +605,7 @@ def mark_auto_export_session(self) -> None: def needs_export(self, export_type: ExportType) -> bool: return self.auto_export_state.is_stale(export_type) - def needs_md_export(self, flavor: str = "pymdown") -> bool: + def needs_md_export(self, flavor: MarkdownFlavorName = "pymdown") -> bool: return self.auto_export_state.is_md_stale(flavor) def _touch(self) -> None: diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index d120b2a51b2..e24fd2330aa 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -719,6 +719,17 @@ def test_export_markdown_infers_qmd_from_output( _assert_success(p) assert "```{marimo .python" in output.read_text() + @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_explicit_flavor_overrides_output( temp_marimo_file: str, tmp_path: Path diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index dba4e14bd15..f622a7f4d21 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -315,6 +315,11 @@ def test_cell(): ) assert "```{marimo .python" in markdown_qmd + 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") # Should use either superfences or fallback format @@ -454,7 +459,9 @@ def test_mystmd_flavor_renders_marimo_notebook_export_syntax(): markdown = flavor.render_document(document) - assert markdown.startswith("---\ntitle: Notebook\n---\n") + assert markdown.startswith( + "---\ntitle: Notebook\nmarimo-version: 0.0.0\n---\n" + ) assert "```{marimo-config}\n---\n" in markdown assert "header: |-\n import os" in markdown assert 'requires-python = ">=3.10"' in markdown diff --git a/tests/_server/api/endpoints/test_export.py b/tests/_server/api/endpoints/test_export.py index ead8593127c..717355df548 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -40,6 +40,23 @@ CODE = uri_encode_component("import marimo as mo") +def _write_markdown_notebook(filename: str) -> None: + lines = [ + "---", + "marimo-version: 0.0.0", + "---", + "", + "```python {.marimo}", + "x = 1", + "```", + "", + ] + Path(filename).write_text( + "\n".join(lines), + encoding="utf-8", + ) + + @with_session(SESSION_ID) def test_export_html(client: TestClient) -> None: session = get_session_manager(client).get_session(SESSION_ID) @@ -219,12 +236,82 @@ def test_export_markdown_with_flavor(client: TestClient) -> None: "/api/export/markdown", headers=HEADERS, json={ - "download": False, + "download": True, "flavor": "qmd", }, ) assert response.status_code == 200 assert "```{marimo .python" in response.text + assert re.match( + r".*filename\*=UTF-8''.*\.qmd", + response.headers["Content-Disposition"], + ) + + +@with_session(SESSION_ID) +def test_export_markdown_with_mystmd_flavor(client: TestClient) -> None: + response = client.post( + "/api/export/markdown", + headers=HEADERS, + json={ + "download": True, + "flavor": "mystmd", + }, + ) + assert response.status_code == 200 + assert "```{marimo} python" in response.text + assert re.match( + r".*filename\*=UTF-8''.*\.myst\.md", + response.headers["Content-Disposition"], + ) + + +@with_session(SESSION_ID) +def test_export_markdown_infers_flavor_from_filename( + client: TestClient, *, temp_marimo_file: str +) -> None: + qmd_file = str(Path(temp_marimo_file).with_suffix(".qmd")) + _write_markdown_notebook(qmd_file) + session = get_session_manager(client).get_session(SESSION_ID) + assert session + session.app_file_manager.filename = qmd_file + + response = client.post( + "/api/export/markdown", + headers=HEADERS, + json={"download": True}, + ) + + assert response.status_code == 200 + assert "```{marimo .python" in response.text + assert re.match( + r".*filename\*=UTF-8''.*\.qmd", + response.headers["Content-Disposition"], + ) + + +@with_session(SESSION_ID) +def test_export_markdown_infers_mystmd_flavor_from_filename( + client: TestClient, *, temp_marimo_file: str +) -> None: + mystmd_file = str(Path(temp_marimo_file).with_suffix(".myst.md")) + _write_markdown_notebook(mystmd_file) + session = get_session_manager(client).get_session(SESSION_ID) + assert session + session.app_file_manager.filename = mystmd_file + + response = client.post( + "/api/export/markdown", + headers=HEADERS, + json={"download": True}, + ) + + assert response.status_code == 200 + assert "```{marimo} python" in response.text + assert re.match( + r".*filename\*=UTF-8''.*\.myst\.md", + response.headers["Content-Disposition"], + ) @pytest.mark.skipif( @@ -427,16 +514,129 @@ def test_auto_export_markdown( # Not modified response assert response.status_code == 304 - # Assert __marimo__ file is created - exported_file = os.path.join( + exported_md = os.path.join( os.path.dirname(temp_marimo_file), "__marimo__", f"{Path(temp_marimo_file).stem}.md", ) + exported_qmd = os.path.join( + os.path.dirname(temp_marimo_file), + "__marimo__", + f"{Path(temp_marimo_file).stem}.qmd", + ) + assert os.path.exists(exported_md) + assert os.path.exists(exported_qmd) + assert "```python {.marimo}" in Path(exported_md).read_text() + assert "```{marimo .python" in Path(exported_qmd).read_text() + + +@with_session(SESSION_ID) +def test_auto_export_markdown_infers_flavor_from_filename( + client: TestClient, *, temp_marimo_file: str +) -> None: + qmd_file = str(Path(temp_marimo_file).with_suffix(".qmd")) + _write_markdown_notebook(qmd_file) + session = get_session_manager(client).get_session(SESSION_ID) + assert session + session.app_file_manager.filename = qmd_file + + response = client.post( + "/api/export/auto_export/markdown", + headers=HEADERS, + json={"download": False}, + ) + assert response.status_code == 200 + assert response.json() == {"success": True} + + response = client.post( + "/api/export/auto_export/markdown", + headers=HEADERS, + json={"download": False}, + ) + assert response.status_code == 304 + + exported_file = os.path.join( + os.path.dirname(qmd_file), + "__marimo__", + f"{Path(qmd_file).stem}.qmd", + ) assert os.path.exists(exported_file) assert "```{marimo .python" in Path(exported_file).read_text() +@with_session(SESSION_ID) +def test_auto_export_markdown_uses_distinct_mystmd_filename( + client: TestClient, *, temp_marimo_file: str +) -> None: + session = get_session_manager(client).get_session(SESSION_ID) + assert session + session.app_file_manager.filename = temp_marimo_file + + response = client.post( + "/api/export/auto_export/markdown", + headers=HEADERS, + json={"download": False, "flavor": "mystmd"}, + ) + assert response.status_code == 200 + assert response.json() == {"success": True} + + response = client.post( + "/api/export/auto_export/markdown", + headers=HEADERS, + json={"download": False, "flavor": "mystmd"}, + ) + assert response.status_code == 304 + + exported_file = os.path.join( + os.path.dirname(temp_marimo_file), + "__marimo__", + f"{Path(temp_marimo_file).stem}.myst.md", + ) + assert os.path.exists(exported_file) + assert "```{marimo} python" in Path(exported_file).read_text() + + +@with_session(SESSION_ID) +def test_auto_export_markdown_strips_mystmd_suffix_for_pymdown( + client: TestClient, *, temp_marimo_file: str +) -> None: + mystmd_file = str(Path(temp_marimo_file).with_suffix(".myst.md")) + _write_markdown_notebook(mystmd_file) + session = get_session_manager(client).get_session(SESSION_ID) + assert session + session.app_file_manager.filename = mystmd_file + + response = client.post( + "/api/export/auto_export/markdown", + headers=HEADERS, + json={"download": False}, + ) + assert response.status_code == 200 + assert response.json() == {"success": True} + + response = client.post( + "/api/export/auto_export/markdown", + headers=HEADERS, + json={"download": False, "flavor": "pymdown"}, + ) + assert response.status_code == 200 + assert response.json() == {"success": True} + + output_dir = os.path.join(os.path.dirname(mystmd_file), "__marimo__") + exported_mystmd = os.path.join( + output_dir, + f"{Path(mystmd_file).stem}.md", + ) + exported_pymdown = os.path.join( + output_dir, + f"{Path(mystmd_file).stem.removesuffix('.myst')}.md", + ) + assert os.path.exists(exported_mystmd) + assert os.path.exists(exported_pymdown) + assert "```{marimo} python" in Path(exported_mystmd).read_text() + assert "```python {.marimo}" in Path(exported_pymdown).read_text() + + @pytest.mark.skipif( not DependencyManager.nbformat.has(), reason="nbformat not installed" ) diff --git a/tests/_server/export/test_export_markdown.py b/tests/_server/export/test_export_markdown.py new file mode 100644 index 00000000000..d6afdad4f7d --- /dev/null +++ b/tests/_server/export/test_export_markdown.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from marimo._server.export import export_as_md +from marimo._utils.marimo_path import MarimoPath + +if TYPE_CHECKING: + from pathlib import Path + + +def _write_markdown_notebook(path: Path) -> None: + lines = [ + "---", + "marimo-version: 0.0.0", + "---", + "", + "```python {.marimo}", + "x = 1", + "```", + "", + ] + path.write_text("\n".join(lines), encoding="utf-8") + + +def test_export_as_md_infers_qmd_extension_from_source_path( + tmp_path: Path, +) -> None: + notebook = tmp_path / "source.qmd" + _write_markdown_notebook(notebook) + + result = export_as_md(MarimoPath(notebook)) + + assert result.download_filename == "source.qmd" + assert "```{marimo .python" in result.text + + +def test_export_as_md_infers_mystmd_extension_from_source_path( + tmp_path: Path, +) -> None: + notebook = tmp_path / "source.myst.md" + _write_markdown_notebook(notebook) + + result = export_as_md(MarimoPath(notebook)) + + assert result.download_filename == "source.myst.md" + assert "```{marimo} python" in result.text + + +def test_export_as_md_explicit_pymdown_strips_mystmd_suffix( + tmp_path: Path, +) -> None: + notebook = tmp_path / "source.myst.md" + _write_markdown_notebook(notebook) + + result = export_as_md(MarimoPath(notebook), flavor="pymdown") + + assert result.download_filename == "source.md" + assert "```{marimo} python" not in result.text + + +def test_export_as_md_uses_qmd_extension_for_qmd_flavor( + temp_marimo_file: str, +) -> None: + result = export_as_md(MarimoPath(temp_marimo_file), flavor="qmd") + + assert result.download_filename == "notebook.qmd" + assert "```{marimo .python" in result.text + + +def test_export_as_md_uses_mystmd_extension_for_mystmd_flavor( + temp_marimo_file: str, +) -> None: + result = export_as_md(MarimoPath(temp_marimo_file), flavor="mystmd") + + assert result.download_filename == "notebook.myst.md" + assert "```{marimo} python" in result.text + + +def test_export_as_md_infers_qmd_extension_from_filename( + temp_marimo_file: str, tmp_path: Path +) -> None: + output = tmp_path / "custom.qmd" + + result = export_as_md(MarimoPath(temp_marimo_file), filename=str(output)) + + assert result.download_filename == "custom.qmd" + assert "```{marimo .python" in result.text + + +def test_export_as_md_explicit_flavor_controls_extension( + temp_marimo_file: str, tmp_path: Path +) -> None: + output = tmp_path / "custom.qmd" + + result = export_as_md( + MarimoPath(temp_marimo_file), + flavor="pymdown", + filename=str(output), + ) + + assert result.download_filename == "custom.md" + assert "```{marimo .python" not in result.text diff --git a/tests/_session/state/test_session_view.py b/tests/_session/state/test_session_view.py index 2ae2cd9a70c..11265eb5bd8 100644 --- a/tests/_session/state/test_session_view.py +++ b/tests/_session/state/test_session_view.py @@ -1328,10 +1328,16 @@ def test_mark_auto_export(session_view: SessionView): assert not session_view.needs_md_export("pymdown") assert session_view.needs_md_export("qmd") + session_view.mark_auto_export_md("qmd") + assert not session_view.needs_md_export("pymdown") + assert not session_view.needs_md_export("qmd") + assert session_view.needs_md_export("mystmd") + session_view._touch() assert session_view.needs_export("html") assert session_view.needs_export("md") assert session_view.needs_md_export("pymdown") + assert session_view.needs_md_export("qmd") session_view.mark_auto_export_html() session_view.mark_auto_export_md() From 8d42a4f7289f900f7a27fb067ace5c01d523ee02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 13:04:50 +0200 Subject: [PATCH 16/20] fix: round-trip myst markdown config --- marimo/_convert/markdown/flavor/__init__.py | 11 +- marimo/_convert/markdown/flavor/base.py | 23 ++- marimo/_convert/markdown/flavor/mystmd.py | 6 +- marimo/_convert/markdown/flavor/pymdown.py | 7 +- marimo/_convert/markdown/flavor/qmd.py | 5 +- marimo/_convert/markdown/to_ir.py | 77 +++++++- tests/_cli/test_cli_export.py | 9 - .../markdown/test_markdown_conversion.py | 162 ++++++++++++++- .../markdown/test_markdown_from_ir.py | 184 +++++------------- tests/_server/api/endpoints/test_export.py | 172 ---------------- tests/_server/export/test_export_markdown.py | 122 ++++++------ 11 files changed, 363 insertions(+), 415 deletions(-) diff --git a/marimo/_convert/markdown/flavor/__init__.py b/marimo/_convert/markdown/flavor/__init__.py index 8ea0da417ce..507fd38ff58 100644 --- a/marimo/_convert/markdown/flavor/__init__.py +++ b/marimo/_convert/markdown/flavor/__init__.py @@ -29,9 +29,11 @@ } ) ) +# 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( { @@ -41,6 +43,7 @@ } ) ) +# Strip known markdown suffixes before applying an output suffix. _MARKDOWN_FILENAME_SUFFIXES = ( ".myst.md", ".markdown", @@ -89,12 +92,10 @@ def markdown_output_filename( filename: str | None, flavor: MarkdownFlavor | MarkdownFlavorName, ) -> str: - """Return the output filename for a rendered markdown flavor. + """Return the filename for a rendered markdown artifact. - 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. + 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}") diff --git a/marimo/_convert/markdown/flavor/base.py b/marimo/_convert/markdown/flavor/base.py index f3df0ccf6c5..bd2933c9e19 100644 --- a/marimo/_convert/markdown/flavor/base.py +++ b/marimo/_convert/markdown/flavor/base.py @@ -11,6 +11,15 @@ MarkdownFlavorName = Literal["pymdown", "qmd", "mystmd"] +def _escape_attribute(value: str) -> str: + return ( + value.replace("&", "&") + .replace('"', """) + .replace("<", "<") + .replace(">", ">") + ) + + @dataclass(frozen=True) class MarkdownCellBlock: text: str @@ -36,9 +45,9 @@ class MarkdownExportDocument: 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. + 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] @@ -90,14 +99,14 @@ def render_blocks() -> Iterator[str]: 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. + 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 markdown text without altering user-authored markdown.""" + """Render user-authored markdown text unchanged.""" @abstractmethod def render_code_cell(self, cell: CodeCellBlock) -> str: diff --git a/marimo/_convert/markdown/flavor/mystmd.py b/marimo/_convert/markdown/flavor/mystmd.py index a04a6133b62..cb1cfcea937 100644 --- a/marimo/_convert/markdown/flavor/mystmd.py +++ b/marimo/_convert/markdown/flavor/mystmd.py @@ -25,8 +25,11 @@ if TYPE_CHECKING: from collections.abc import Mapping +# Metadata emitted through the `{marimo-config}` directive. _CONFIG_KEYS = {"header", "pyproject"} +# marimo-specific metadata filtered before writing MyST frontmatter. _MARIMO_METADATA_KEYS = {"width"} +# PEP 723 script metadata blocks embedded in exported notebook headers. _SCRIPT_METADATA_RE = re.compile( r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s" r"(?P<content>(^#(| .*)$\s)+)^# ///$" @@ -36,8 +39,7 @@ class MystmdMarkdownFlavor(MarkdownFlavor): """Render marimo notebooks as mystmd markdown. - mystmd is directive-oriented, so this flavor emits marimo cells with body - option lines instead of inline fence attributes. Page-level execution + mystmd uses directive option lines for cell metadata. Page-level execution metadata is emitted through a `{marimo-config}` directive. """ diff --git a/marimo/_convert/markdown/flavor/pymdown.py b/marimo/_convert/markdown/flavor/pymdown.py index fcf6ed8b5fb..5bdf66d2151 100644 --- a/marimo/_convert/markdown/flavor/pymdown.py +++ b/marimo/_convert/markdown/flavor/pymdown.py @@ -8,6 +8,7 @@ MarkdownCellBlock, MarkdownExportDocument, MarkdownFlavor, + _escape_attribute, ) from marimo._dependencies.dependencies import DependencyManager @@ -68,7 +69,11 @@ def _render_code_fence( attributes = dict(attributes or {}) language = attributes.pop("language", "python") attribute_str = " ".join( - [""] + [f'{key}="{value}"' for key, value in attributes.items()] + [""] + + [ + f'{key}="{_escape_attribute(str(value))}"' + for key, value in attributes.items() + ] ) guard = "```" while guard in code: diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py index f9bdeb882e7..59b2addb470 100644 --- a/marimo/_convert/markdown/flavor/qmd.py +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -8,6 +8,7 @@ MarkdownCellBlock, MarkdownExportDocument, MarkdownFlavor, + _escape_attribute, ) @@ -76,7 +77,3 @@ def _render_code_fence( head = self._code_fence_head(guard, language, attribute_str) parts = [head, code, guard, ""] return "\n".join(parts) - - -def _escape_attribute(value: str) -> str: - return value.replace("&", "&").replace('"', """) diff --git a/marimo/_convert/markdown/to_ir.py b/marimo/_convert/markdown/to_ir.py index 9910bbb6b37..3781da02ea5 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 ( @@ -67,8 +68,12 @@ _MYSTMD_MARIMO_HEADER_RE = re.compile( r"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$" ) +_MYSTMD_MARIMO_CONFIG_HEADER_RE = re.compile( + r"^(?P<fence>`{3,})\{marimo-config\}\s*$" +) _MYSTMD_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$") _MYSTMD_DIRECTIVE_CLASS = "mystmd-marimo" +_MYSTMD_CONFIG_KEYS = {"header", "pyproject"} ConvertKeys = Literal["marimo-ir"] @@ -91,7 +96,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 {} @@ -99,6 +107,21 @@ def _is_mystmd_marimo_directive_header(line: str) -> bool: return bool(_MYSTMD_MARIMO_HEADER_RE.match(line)) +def _is_mystmd_marimo_config_header(line: str) -> bool: + return bool(_MYSTMD_MARIMO_CONFIG_HEADER_RE.match(line)) + + +def _is_mystmd_marimo_header(line: str) -> bool: + return _is_mystmd_marimo_directive_header( + line + ) or _is_mystmd_marimo_config_header(line) + + +def _is_closing_fence(line: str, opening_fence: str) -> bool: + stripped = line.strip() + return len(stripped) >= len(opening_fence) and set(stripped) == {"`"} + + def _is_preprocessed_mystmd_marimo_fence(line: str) -> bool: return f".{_MYSTMD_DIRECTIVE_CLASS}" in line @@ -122,8 +145,35 @@ def _extract_mystmd_directive_options( return options, lines[body_start:] +def _extract_mystmd_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 _MYSTMD_CONFIG_KEYS and isinstance(value, str) + } + + def _is_code_tag(text: str) -> bool: head = text.split("\n")[0].strip() + if _is_mystmd_marimo_config_header(head): + return False legacy_format = bool(re.search(r"\{.*python.*\}", head)) legacy_format |= bool(re.search(r"\{.*sql.*\}", head)) if DependencyManager.new_superfences.has_required_version(quiet=True): @@ -425,11 +475,33 @@ def run(self, lines: list[str]) -> list[str]: class MystmdMarimoPreprocessor(Preprocessor): """Normalize mystmd marimo directive fences before SuperFences parses them.""" + def __init__(self, md: MarimoMdParser): + super().__init__(md) + self.md: MarimoMdParser = md + def run(self, lines: list[str]) -> list[str]: normalized: list[str] = [] index = 0 while index < len(lines): + config_match = _MYSTMD_MARIMO_CONFIG_HEADER_RE.match(lines[index]) + if config_match is not None: + fence = config_match.group("fence") + closing_index = index + 1 + while closing_index < len(lines): + if _is_closing_fence(lines[closing_index], fence): + metadata = _extract_mystmd_config_metadata( + lines[index + 1 : closing_index] + ) + self.md.meta.update(metadata) + index = closing_index + 1 + break + closing_index += 1 + else: + normalized.extend(lines[index:]) + break + continue + match = _MYSTMD_MARIMO_HEADER_RE.match(lines[index]) if match is None: normalized.append(lines[index]) @@ -584,8 +656,7 @@ def convert_from_md_to_marimo_ir( notebook = MarimoMdParser( output_format="marimo-ir", enable_mystmd=any( - _is_mystmd_marimo_directive_header(line) - for line in text.splitlines() + _is_mystmd_marimo_header(line) for line in text.splitlines() ), ).convert(text) assert isinstance(notebook, NotebookSerializationV1) diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index e24fd2330aa..26dce7e83f2 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -748,15 +748,6 @@ def test_export_markdown_explicit_flavor_overrides_output( _assert_success(p) assert "```{marimo .python" not in output.read_text() - @staticmethod - def test_export_markdown_with_invalid_flavor( - temp_marimo_file: str, - ) -> None: - p = _run_export("md", temp_marimo_file, "--flavor", "unknown") - _assert_failure(p) - assert "invalid value for '--flavor'" in p.output - assert "'mystmd'" in p.output - @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 784759ac3f8..f909dfedc07 100644 --- a/tests/_convert/markdown/test_markdown_conversion.py +++ b/tests/_convert/markdown/test_markdown_conversion.py @@ -12,10 +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.from_ir import convert_from_ir_to_markdown from marimo._convert.markdown.to_ir import ( - MarimoMdParser, 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 @@ -173,15 +179,155 @@ def test_mystmd_marimo_directives() -> None: ) -def test_mystmd_preprocessor_registers_conditionally() -> None: - plain_parser = MarimoMdParser(output_format="marimo-ir") - mystmd_parser = MarimoMdParser( - output_format="marimo-ir", - enable_mystmd=True, +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_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_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_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 & <c>' + + markdown = convert_from_ir_to_markdown( + notebook_ir, filename="notebook.md", flavor="pymdown" ) - assert "mystmd-marimo" not in plain_parser.preprocessors - assert "mystmd-marimo" in mystmd_parser.preprocessors + assert 'name="a"b & <c>"' in markdown def test_no_frontmatter() -> None: diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index f622a7f4d21..de1be08cee8 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -1,15 +1,11 @@ # 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_flavor_from_filename, - normalize_markdown_flavor, -) -from marimo._convert.markdown.flavor.base import ( - CodeCellBlock, - MarkdownCellBlock, - MarkdownExportDocument, + markdown_output_filename, ) from marimo._convert.markdown.from_ir import ( _format_filename_title, @@ -355,143 +351,57 @@ def test_cell(): ) -def test_markdown_flavor_renders_export_document(): - """Test that the PyMdown flavor renders preamble and block syntax.""" - flavor = markdown_flavor_from_filename("notebook.md") - assert flavor.name == "pymdown" - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - MarkdownCellBlock("# First"), - MarkdownCellBlock("# Second"), - CodeCellBlock("x = 1", "python", {}), - ], - ) - - markdown = flavor.render_document(document) - - assert markdown.startswith("---\ntitle: Notebook\n---") - assert "# First\n<!---->\n# Second\n\n" in markdown - assert "x = 1" in markdown - - -def test_qmd_flavor_renders_export_document(): - """Test that qmd flavor renders executable fence syntax.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[CodeCellBlock("x = 1", "python", {})], - ) - - markdown = flavor.render_document(document) - - assert "filters:" not in markdown - assert "```{marimo .python}" in markdown - - -def test_qmd_flavor_escapes_code_cell_attributes(): - """Test that qmd code fence attributes escape quotes and ampersands.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - CodeCellBlock( - "x = 1", - "python", - {"name": 'a"b & c', "engine": 'duck&"db'}, - ) - ], - ) - - markdown = flavor.render_document(document) - - assert 'name="a"b & c"' in markdown - assert 'engine="duck&"db"' in markdown - - -def test_qmd_flavor_preserves_explicit_filters(): - """Test that qmd flavor serializes user-provided filters.""" - flavor = markdown_flavor_from_filename("notebook.qmd") - document = MarkdownExportDocument( - metadata={"title": "Notebook", "filters": ["custom-filter"]}, - header=None, - blocks=[CodeCellBlock("x = 1", "python", {})], - ) - - markdown = flavor.render_document(document) - - assert markdown.startswith( - "---\ntitle: Notebook\nfilters:\n- custom-filter\n---\n" - ) +@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 -def test_mystmd_flavor_renders_marimo_notebook_export_syntax(): - """Test that mystmd flavor renders marimo notebook authoring syntax.""" - flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") - pep723_header = ( - "import os", - "# /// script", - '# requires-python = ">=3.10"', - "# dependencies = [", - '# "pandas",', - "# ]", - "# ///", - ) - document = MarkdownExportDocument( - metadata={ - "title": "Notebook", - "marimo-version": "0.0.0", - "width": "medium", - "header": "\n".join(pep723_header), - }, - header=None, - blocks=[ - CodeCellBlock( - source="x = 1", - language="python", - options={"hide_code": "true", "unparsable": "true"}, - ) - ], +@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, ) - markdown = flavor.render_document(document) - - assert markdown.startswith( - "---\ntitle: Notebook\nmarimo-version: 0.0.0\n---\n" - ) - assert "```{marimo-config}\n---\n" in markdown - assert "header: |-\n import os" in markdown - assert 'requires-python = ">=3.10"' in markdown - expected_cell = ( - "```{marimo} python", - ":hide-code: true", - ":unparsable: true", - "", - "x = 1", - "```", - ) - assert "\n".join(expected_cell) in markdown - - -def test_mystmd_flavor_grows_code_fence_guard(): - """Test that mystmd code fences are valid when source contains backticks.""" - flavor = normalize_markdown_flavor("mystmd", filename="notebook.md") - document = MarkdownExportDocument( - metadata={"title": "Notebook"}, - header=None, - blocks=[ - CodeCellBlock( - source='mo.md("""\n```python\nx = 1\n```\n""")', - language="python", + notebook = NotebookSerializationV1( + app=AppInstantiation(options={}), + cells=[ + CellDef( + name='a"b & <c>', + code="x = 1", options={}, ) ], + violations=[], + valid=True, + filename="notebook.py", ) - markdown = flavor.render_document(document) + markdown = convert_from_ir_to_markdown( + notebook, filename=filename, flavor=flavor + ) - assert "````{marimo} python" in markdown - assert markdown.rstrip().endswith("````") + 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 717355df548..513521fae54 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -40,23 +40,6 @@ CODE = uri_encode_component("import marimo as mo") -def _write_markdown_notebook(filename: str) -> None: - lines = [ - "---", - "marimo-version: 0.0.0", - "---", - "", - "```python {.marimo}", - "x = 1", - "```", - "", - ] - Path(filename).write_text( - "\n".join(lines), - encoding="utf-8", - ) - - @with_session(SESSION_ID) def test_export_html(client: TestClient) -> None: session = get_session_manager(client).get_session(SESSION_ID) @@ -266,54 +249,6 @@ def test_export_markdown_with_mystmd_flavor(client: TestClient) -> None: ) -@with_session(SESSION_ID) -def test_export_markdown_infers_flavor_from_filename( - client: TestClient, *, temp_marimo_file: str -) -> None: - qmd_file = str(Path(temp_marimo_file).with_suffix(".qmd")) - _write_markdown_notebook(qmd_file) - session = get_session_manager(client).get_session(SESSION_ID) - assert session - session.app_file_manager.filename = qmd_file - - response = client.post( - "/api/export/markdown", - headers=HEADERS, - json={"download": True}, - ) - - assert response.status_code == 200 - assert "```{marimo .python" in response.text - assert re.match( - r".*filename\*=UTF-8''.*\.qmd", - response.headers["Content-Disposition"], - ) - - -@with_session(SESSION_ID) -def test_export_markdown_infers_mystmd_flavor_from_filename( - client: TestClient, *, temp_marimo_file: str -) -> None: - mystmd_file = str(Path(temp_marimo_file).with_suffix(".myst.md")) - _write_markdown_notebook(mystmd_file) - session = get_session_manager(client).get_session(SESSION_ID) - assert session - session.app_file_manager.filename = mystmd_file - - response = client.post( - "/api/export/markdown", - headers=HEADERS, - json={"download": True}, - ) - - assert response.status_code == 200 - assert "```{marimo} python" in response.text - assert re.match( - r".*filename\*=UTF-8''.*\.myst\.md", - response.headers["Content-Disposition"], - ) - - @pytest.mark.skipif( not DependencyManager.nbformat.has(), reason="nbformat not installed" ) @@ -530,113 +465,6 @@ def test_auto_export_markdown( assert "```{marimo .python" in Path(exported_qmd).read_text() -@with_session(SESSION_ID) -def test_auto_export_markdown_infers_flavor_from_filename( - client: TestClient, *, temp_marimo_file: str -) -> None: - qmd_file = str(Path(temp_marimo_file).with_suffix(".qmd")) - _write_markdown_notebook(qmd_file) - session = get_session_manager(client).get_session(SESSION_ID) - assert session - session.app_file_manager.filename = qmd_file - - response = client.post( - "/api/export/auto_export/markdown", - headers=HEADERS, - json={"download": False}, - ) - assert response.status_code == 200 - assert response.json() == {"success": True} - - response = client.post( - "/api/export/auto_export/markdown", - headers=HEADERS, - json={"download": False}, - ) - assert response.status_code == 304 - - exported_file = os.path.join( - os.path.dirname(qmd_file), - "__marimo__", - f"{Path(qmd_file).stem}.qmd", - ) - assert os.path.exists(exported_file) - assert "```{marimo .python" in Path(exported_file).read_text() - - -@with_session(SESSION_ID) -def test_auto_export_markdown_uses_distinct_mystmd_filename( - client: TestClient, *, temp_marimo_file: str -) -> None: - session = get_session_manager(client).get_session(SESSION_ID) - assert session - session.app_file_manager.filename = temp_marimo_file - - response = client.post( - "/api/export/auto_export/markdown", - headers=HEADERS, - json={"download": False, "flavor": "mystmd"}, - ) - assert response.status_code == 200 - assert response.json() == {"success": True} - - response = client.post( - "/api/export/auto_export/markdown", - headers=HEADERS, - json={"download": False, "flavor": "mystmd"}, - ) - assert response.status_code == 304 - - exported_file = os.path.join( - os.path.dirname(temp_marimo_file), - "__marimo__", - f"{Path(temp_marimo_file).stem}.myst.md", - ) - assert os.path.exists(exported_file) - assert "```{marimo} python" in Path(exported_file).read_text() - - -@with_session(SESSION_ID) -def test_auto_export_markdown_strips_mystmd_suffix_for_pymdown( - client: TestClient, *, temp_marimo_file: str -) -> None: - mystmd_file = str(Path(temp_marimo_file).with_suffix(".myst.md")) - _write_markdown_notebook(mystmd_file) - session = get_session_manager(client).get_session(SESSION_ID) - assert session - session.app_file_manager.filename = mystmd_file - - response = client.post( - "/api/export/auto_export/markdown", - headers=HEADERS, - json={"download": False}, - ) - assert response.status_code == 200 - assert response.json() == {"success": True} - - response = client.post( - "/api/export/auto_export/markdown", - headers=HEADERS, - json={"download": False, "flavor": "pymdown"}, - ) - assert response.status_code == 200 - assert response.json() == {"success": True} - - output_dir = os.path.join(os.path.dirname(mystmd_file), "__marimo__") - exported_mystmd = os.path.join( - output_dir, - f"{Path(mystmd_file).stem}.md", - ) - exported_pymdown = os.path.join( - output_dir, - f"{Path(mystmd_file).stem.removesuffix('.myst')}.md", - ) - assert os.path.exists(exported_mystmd) - assert os.path.exists(exported_pymdown) - assert "```{marimo} python" in Path(exported_mystmd).read_text() - assert "```python {.marimo}" in Path(exported_pymdown).read_text() - - @pytest.mark.skipif( not DependencyManager.nbformat.has(), reason="nbformat not installed" ) diff --git a/tests/_server/export/test_export_markdown.py b/tests/_server/export/test_export_markdown.py index d6afdad4f7d..f4bc7f3fb53 100644 --- a/tests/_server/export/test_export_markdown.py +++ b/tests/_server/export/test_export_markdown.py @@ -2,6 +2,9 @@ from typing import TYPE_CHECKING +import pytest + +from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._server.export import export_as_md from marimo._utils.marimo_path import MarimoPath @@ -23,81 +26,66 @@ def _write_markdown_notebook(path: Path) -> None: path.write_text("\n".join(lines), encoding="utf-8") -def test_export_as_md_infers_qmd_extension_from_source_path( +@pytest.mark.parametrize( + ("source_name", "flavor", "download_filename", "expected", "absent"), + [ + ("source.qmd", None, "source.qmd", "```{marimo .python", None), + ("source.myst.md", None, "source.myst.md", "```{marimo} python", None), + ("source.myst.md", "pymdown", "source.md", "```python", "```{marimo}"), + ], +) +def test_export_as_md_uses_source_markdown_filename( tmp_path: Path, + source_name: str, + flavor: MarkdownFlavorName | None, + download_filename: str, + expected: str, + absent: str | None, ) -> None: - notebook = tmp_path / "source.qmd" + notebook = tmp_path / source_name _write_markdown_notebook(notebook) - result = export_as_md(MarimoPath(notebook)) - - assert result.download_filename == "source.qmd" - assert "```{marimo .python" in result.text - - -def test_export_as_md_infers_mystmd_extension_from_source_path( - tmp_path: Path, -) -> None: - notebook = tmp_path / "source.myst.md" - _write_markdown_notebook(notebook) - - result = export_as_md(MarimoPath(notebook)) - - assert result.download_filename == "source.myst.md" - assert "```{marimo} python" in result.text - - -def test_export_as_md_explicit_pymdown_strips_mystmd_suffix( - tmp_path: Path, -) -> None: - notebook = tmp_path / "source.myst.md" - _write_markdown_notebook(notebook) - - result = export_as_md(MarimoPath(notebook), flavor="pymdown") - - assert result.download_filename == "source.md" - assert "```{marimo} python" not in result.text - - -def test_export_as_md_uses_qmd_extension_for_qmd_flavor( - temp_marimo_file: str, -) -> None: - result = export_as_md(MarimoPath(temp_marimo_file), flavor="qmd") - - assert result.download_filename == "notebook.qmd" - assert "```{marimo .python" in result.text - - -def test_export_as_md_uses_mystmd_extension_for_mystmd_flavor( + result = export_as_md(MarimoPath(notebook), flavor=flavor) + + assert result.download_filename == download_filename + assert expected in result.text + if absent: + assert absent not in result.text + + +@pytest.mark.parametrize( + ("filename", "flavor", "download_filename", "expected", "absent"), + [ + ("custom.qmd", None, "custom.qmd", "```{marimo .python", None), + ( + "custom.qmd", + "pymdown", + "custom.md", + "```python", + "```{marimo .python", + ), + (None, "qmd", "notebook.qmd", "```{marimo .python", None), + (None, "mystmd", "notebook.myst.md", "```{marimo} python", None), + ], +) +def test_export_as_md_uses_requested_filename_and_flavor( temp_marimo_file: str, + tmp_path: Path, + filename: str | None, + flavor: MarkdownFlavorName | None, + download_filename: str, + expected: str, + absent: str | None, ) -> None: - result = export_as_md(MarimoPath(temp_marimo_file), flavor="mystmd") - - assert result.download_filename == "notebook.myst.md" - assert "```{marimo} python" in result.text - - -def test_export_as_md_infers_qmd_extension_from_filename( - temp_marimo_file: str, tmp_path: Path -) -> None: - output = tmp_path / "custom.qmd" - - result = export_as_md(MarimoPath(temp_marimo_file), filename=str(output)) - - assert result.download_filename == "custom.qmd" - assert "```{marimo .python" in result.text - - -def test_export_as_md_explicit_flavor_controls_extension( - temp_marimo_file: str, tmp_path: Path -) -> None: - output = tmp_path / "custom.qmd" + output = str(tmp_path / filename) if filename else None result = export_as_md( MarimoPath(temp_marimo_file), - flavor="pymdown", - filename=str(output), + flavor=flavor, + filename=output, ) - assert result.download_filename == "custom.md" - assert "```{marimo .python" not in result.text + assert result.download_filename == download_filename + assert expected in result.text + if absent: + assert absent not in result.text From 8a434ecd85791af06e9df0df8d251f3aa38dc73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 13:28:46 +0200 Subject: [PATCH 17/20] chore: defer api support to a next pr --- marimo/_cli/export/commands.py | 41 ++++++++- marimo/_pyodide/pyodide_session.py | 11 +-- marimo/_server/api/endpoints/export.py | 36 ++------ marimo/_server/export/__init__.py | 23 +---- marimo/_server/export/exporter.py | 21 +---- marimo/_server/models/export.py | 4 +- marimo/_session/state/session_view.py | 21 +---- packages/openapi/api.yaml | 13 +-- packages/openapi/src/api.ts | 5 +- tests/_pyodide/test_pyodide_session.py | 7 -- tests/_server/api/endpoints/test_export.py | 64 +------------- tests/_server/export/test_export_markdown.py | 91 -------------------- tests/_server/files/test_os_file_system.py | 2 +- tests/_server/test_file_manager.py | 2 + tests/_session/state/test_session_view.py | 10 --- 15 files changed, 71 insertions(+), 280 deletions(-) delete mode 100644 tests/_server/export/test_export_markdown.py diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 103d7665aed..898edd5aae8 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, @@ -171,6 +176,38 @@ 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) + export_filename = filename or ir.filename or path.short_name + markdown_flavor = normalize_markdown_flavor( + flavor, filename=export_filename + ) + return ExportResult( + contents=MarimoConvert.from_ir(ir).to_markdown( + filename=export_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. @@ -401,7 +438,7 @@ def md( filename = str(output) if output is not None else None def export_callback(file_path: MarimoPath) -> ExportResult: - return export_as_md(file_path, flavor=flavor, filename=filename) + 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/_pyodide/pyodide_session.py b/marimo/_pyodide/pyodide_session.py index f93bd58e8e4..c056e217d98 100644 --- a/marimo/_pyodide/pyodide_session.py +++ b/marimo/_pyodide/pyodide_session.py @@ -36,10 +36,7 @@ from marimo._runtime.marimo_pdb import MarimoPdb from marimo._server.export.exporter import Exporter from marimo._server.files.os_file_system import OSFileSystem -from marimo._server.models.export import ( - ExportAsHTMLRequest, - ExportAsMarkdownRequest, -) +from marimo._server.models.export import ExportAsHTMLRequest from marimo._server.models.files import ( FileCopyRequest, FileCopyResponse, @@ -418,10 +415,8 @@ def export_html(self, request: str) -> str: return json.dumps(html) def export_markdown(self, request: str) -> str: - parsed = self._parse(request, ExportAsMarkdownRequest) - md = convert_from_ir_to_markdown( - self.session.app_manager.app.to_ir(), flavor=parsed.flavor - ) + del request + md = convert_from_ir_to_markdown(self.session.app_manager.app.to_ir()) return json.dumps(md) def _parse(self, request: str, cls: type[T]) -> T: diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 42e1f289bb1..1500f79f70c 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -19,10 +19,6 @@ 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 @@ -281,17 +277,11 @@ async def export_as_markdown( detail="File must be saved before downloading", ) - filename = app_file_manager.filename or "notebook.md" - markdown_flavor = normalize_markdown_flavor(body.flavor, filename=filename) - markdown = convert_from_ir_to_markdown( - app_file_manager.app.to_ir(), - filename=filename, - flavor=markdown_flavor, - ) + markdown = convert_from_ir_to_markdown(app_file_manager.app.to_ir()) if body.download: - download_filename = markdown_output_filename( - app_file_manager.filename, markdown_flavor + download_filename = get_download_filename( + app_file_manager.filename, "md" ) headers = make_download_headers(download_filename) else: @@ -392,7 +382,6 @@ async def auto_export_as_markdown( description: File must be saved before downloading """ app_state = AppState(request) - body = await parse_request(request, cls=ExportAsMarkdownRequest) session = app_state.require_current_session() session_view = session.session_view @@ -402,11 +391,8 @@ async def auto_export_as_markdown( detail="File must have a name before exporting", ) - filename = session.app_file_manager.filename or "notebook.md" - markdown_flavor = normalize_markdown_flavor(body.flavor, filename=filename) - - # If we have already exported to Markdown with this flavor, don't do it again. - if not session_view.needs_md_export(markdown_flavor.name): + # If we have already exported to Markdown, don't do it again + if not session_view.needs_export("md"): LOGGER.debug("Already auto-exported to Markdown") return PlainTextResponse(status_code=HTTPStatus.NOT_MODIFIED) @@ -415,21 +401,15 @@ async def _background_export() -> None: session.app_file_manager.reload() markdown = convert_from_ir_to_markdown( - session.app_file_manager.app.to_ir(), - filename=filename, - flavor=markdown_flavor, + session.app_file_manager.app.to_ir() ) - # Save the Markdown file to disk, at `.marimo/<filename>.<extension>` + # Save the Markdown file to disk, at `.marimo/<filename>.md` await auto_exporter.save_md( filename=session.app_file_manager.filename, markdown=markdown, - download_filename=markdown_output_filename( - session.app_file_manager.filename, - markdown_flavor, - ), ) - session_view.mark_auto_export_md(markdown_flavor.name) + session_view.mark_auto_export_md() return JSONResponse( content=asdict(SuccessResponse()), diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py index 52ef2dc77ed..18473723924 100644 --- a/marimo/_server/export/__init__.py +++ b/marimo/_server/export/__init__.py @@ -19,10 +19,6 @@ ) 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 ( @@ -55,7 +51,6 @@ if TYPE_CHECKING: from collections.abc import Mapping - from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._server.export._pdf_raster import PDFRasterizationOptions from marimo._server.export._status import PDFExportStatusCallback from marimo._session.state.session_view import SessionView @@ -107,23 +102,11 @@ def export_as_script(path: MarimoPath) -> ExportResult: ) -def export_as_md( - path: MarimoPath, - flavor: MarkdownFlavorName | None = None, - filename: str | None = None, -) -> ExportResult: +def export_as_md(path: MarimoPath) -> ExportResult: ir = _as_ir(path) - export_filename = filename or ir.filename or path.short_name - markdown_flavor = normalize_markdown_flavor( - flavor, filename=export_filename - ) return ExportResult( - contents=MarimoConvert.from_ir(ir).to_markdown( - filename=export_filename, flavor=markdown_flavor - ), - download_filename=markdown_output_filename( - export_filename, markdown_flavor - ), + contents=MarimoConvert.from_ir(ir).to_markdown(), + download_filename=get_download_filename(path.short_name, "md"), did_error=False, ) diff --git a/marimo/_server/export/exporter.py b/marimo/_server/export/exporter.py index 98c77724292..cee285b552e 100644 --- a/marimo/_server/export/exporter.py +++ b/marimo/_server/export/exporter.py @@ -736,16 +736,10 @@ def __init__(self) -> None: ) async def _save_file( - self, - filename: str | None, - content: str, - extension: str, - download_filename: str | None = None, + self, filename: str | None, content: str, extension: str ) -> None: notebook_path = get_filename(filename) - download_name = download_filename or get_download_filename( - filename, extension - ) + download_name = get_download_filename(filename, extension) export_dir = notebook_output_dir(notebook_path) await self._ensure_export_dir_async(export_dir) @@ -760,15 +754,8 @@ async def _save_file( async def save_html(self, filename: str | None, html: str) -> None: await self._save_file(filename, html, "html") - async def save_md( - self, - filename: str | None, - markdown: str, - download_filename: str | None = None, - ) -> None: - await self._save_file( - filename, markdown, "md", download_filename=download_filename - ) + async def save_md(self, filename: str | None, markdown: str) -> None: + await self._save_file(filename, markdown, "md") async def save_ipynb(self, filename: str | None, ipynb: str) -> None: await self._save_file(filename, ipynb, "ipynb") diff --git a/marimo/_server/models/export.py b/marimo/_server/models/export.py index 1b894487772..97602848240 100644 --- a/marimo/_server/models/export.py +++ b/marimo/_server/models/export.py @@ -5,7 +5,6 @@ import msgspec -from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._messaging.mimetypes import MimeBundleTuple from marimo._types.ids import CellId_t @@ -26,8 +25,7 @@ class ExportAsIPYNBRequest(msgspec.Struct, rename="camel"): class ExportAsMarkdownRequest(msgspec.Struct, rename="camel"): - download: bool = False - flavor: MarkdownFlavorName | None = None + download: bool ExportPDFPreset = Literal["document", "slides"] diff --git a/marimo/_session/state/session_view.py b/marimo/_session/state/session_view.py index b6c50842c10..805ef69ca38 100644 --- a/marimo/_session/state/session_view.py +++ b/marimo/_session/state/session_view.py @@ -2,11 +2,10 @@ from __future__ import annotations import time -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Literal, cast from marimo import _loggers -from marimo._convert.markdown.flavor.base import MarkdownFlavorName from marimo._data.models import DataSourceConnection, DataTable from marimo._messaging.cell_output import CellChannel, CellOutput from marimo._messaging.mimetypes import KnownMimeType, MimeBundleTuple @@ -115,14 +114,12 @@ def to_notification(self) -> ModelLifecycleNotification: class AutoExportState: html: bool = False md: bool = False - md_flavors: set[MarkdownFlavorName] = field(default_factory=set) ipynb: bool = False session: bool = False def mark_all_stale(self) -> None: self.html = False self.md = False - self.md_flavors.clear() self.ipynb = False self.session = False @@ -132,13 +129,6 @@ def is_stale(self, export_type: ExportType) -> bool: def mark_exported(self, export_type: ExportType) -> None: setattr(self, export_type, True) - def is_md_stale(self, flavor: MarkdownFlavorName) -> bool: - return flavor not in self.md_flavors - - def mark_md_exported(self, flavor: MarkdownFlavorName) -> None: - self.md = True - self.md_flavors.add(flavor) - class SessionView: """A representation of a session state for replay and serialization. @@ -591,10 +581,8 @@ def is_empty(self) -> bool: def mark_auto_export_html(self) -> None: self.auto_export_state.mark_exported("html") - def mark_auto_export_md( - self, flavor: MarkdownFlavorName = "pymdown" - ) -> None: - self.auto_export_state.mark_md_exported(flavor) + def mark_auto_export_md(self) -> None: + self.auto_export_state.mark_exported("md") def mark_auto_export_ipynb(self) -> None: self.auto_export_state.mark_exported("ipynb") @@ -605,9 +593,6 @@ def mark_auto_export_session(self) -> None: def needs_export(self, export_type: ExportType) -> bool: return self.auto_export_state.is_stale(export_type) - def needs_md_export(self, flavor: MarkdownFlavorName = "pymdown") -> bool: - return self.auto_export_state.is_md_stale(flavor) - def _touch(self) -> None: self.auto_export_state.mark_all_stale() diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 87f8e783150..27dc721cb70 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -1620,17 +1620,9 @@ components: ExportAsMarkdownRequest: properties: download: - default: false type: boolean - flavor: - anyOf: - - enum: - - mystmd - - pymdown - - qmd - - type: 'null' - default: null - required: [] + required: + - download title: ExportAsMarkdownRequest type: object ExportAsPDFRequest: @@ -7123,3 +7115,4 @@ paths: summary: Get the auth token for the current session tags: - auth + diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index bca94e3152e..e4b05c760a1 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -4336,10 +4336,7 @@ export interface components { }; /** ExportAsMarkdownRequest */ ExportAsMarkdownRequest: { - /** @default false */ - download?: boolean; - /** @default null */ - flavor?: ("mystmd" | "pymdown" | "qmd") | null; + download: boolean; }; /** ExportAsPDFRequest */ ExportAsPDFRequest: { diff --git a/tests/_pyodide/test_pyodide_session.py b/tests/_pyodide/test_pyodide_session.py index 804c0156eeb..7f5ec0197db 100644 --- a/tests/_pyodide/test_pyodide_session.py +++ b/tests/_pyodide/test_pyodide_session.py @@ -902,13 +902,6 @@ def test_pyodide_bridge_export_markdown( assert isinstance(markdown, str) assert len(markdown) > 0 - result = pyodide_bridge.export_markdown( - json.dumps({"download": False, "flavor": "qmd"}) - ) - markdown = json.loads(result) - - assert "```{marimo .python" in markdown - async def test_pyodide_bridge_read_snippets( pyodide_bridge: PyodideBridge, diff --git a/tests/_server/api/endpoints/test_export.py b/tests/_server/api/endpoints/test_export.py index 513521fae54..2fb2310c194 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -213,42 +213,6 @@ def test_export_markdown(client: TestClient) -> None: ) -@with_session(SESSION_ID) -def test_export_markdown_with_flavor(client: TestClient) -> None: - response = client.post( - "/api/export/markdown", - headers=HEADERS, - json={ - "download": True, - "flavor": "qmd", - }, - ) - assert response.status_code == 200 - assert "```{marimo .python" in response.text - assert re.match( - r".*filename\*=UTF-8''.*\.qmd", - response.headers["Content-Disposition"], - ) - - -@with_session(SESSION_ID) -def test_export_markdown_with_mystmd_flavor(client: TestClient) -> None: - response = client.post( - "/api/export/markdown", - headers=HEADERS, - json={ - "download": True, - "flavor": "mystmd", - }, - ) - assert response.status_code == 200 - assert "```{marimo} python" in response.text - assert re.match( - r".*filename\*=UTF-8''.*\.myst\.md", - response.headers["Content-Disposition"], - ) - - @pytest.mark.skipif( not DependencyManager.nbformat.has(), reason="nbformat not installed" ) @@ -432,37 +396,15 @@ def test_auto_export_markdown( headers=HEADERS, json={ "download": False, - "flavor": "qmd", - }, - ) - assert response.status_code == 200 - assert response.json() == {"success": True} - - response = client.post( - "/api/export/auto_export/markdown", - headers=HEADERS, - json={ - "download": False, - "flavor": "qmd", }, ) # Not modified response assert response.status_code == 304 - exported_md = os.path.join( - os.path.dirname(temp_marimo_file), - "__marimo__", - f"{Path(temp_marimo_file).stem}.md", - ) - exported_qmd = os.path.join( - os.path.dirname(temp_marimo_file), - "__marimo__", - f"{Path(temp_marimo_file).stem}.qmd", + # Assert __marimo__ file is created + assert os.path.exists( + os.path.join(os.path.dirname(temp_marimo_file), "__marimo__") ) - assert os.path.exists(exported_md) - assert os.path.exists(exported_qmd) - assert "```python {.marimo}" in Path(exported_md).read_text() - assert "```{marimo .python" in Path(exported_qmd).read_text() @pytest.mark.skipif( diff --git a/tests/_server/export/test_export_markdown.py b/tests/_server/export/test_export_markdown.py deleted file mode 100644 index f4bc7f3fb53..00000000000 --- a/tests/_server/export/test_export_markdown.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from marimo._convert.markdown.flavor.base import MarkdownFlavorName -from marimo._server.export import export_as_md -from marimo._utils.marimo_path import MarimoPath - -if TYPE_CHECKING: - from pathlib import Path - - -def _write_markdown_notebook(path: Path) -> None: - lines = [ - "---", - "marimo-version: 0.0.0", - "---", - "", - "```python {.marimo}", - "x = 1", - "```", - "", - ] - path.write_text("\n".join(lines), encoding="utf-8") - - -@pytest.mark.parametrize( - ("source_name", "flavor", "download_filename", "expected", "absent"), - [ - ("source.qmd", None, "source.qmd", "```{marimo .python", None), - ("source.myst.md", None, "source.myst.md", "```{marimo} python", None), - ("source.myst.md", "pymdown", "source.md", "```python", "```{marimo}"), - ], -) -def test_export_as_md_uses_source_markdown_filename( - tmp_path: Path, - source_name: str, - flavor: MarkdownFlavorName | None, - download_filename: str, - expected: str, - absent: str | None, -) -> None: - notebook = tmp_path / source_name - _write_markdown_notebook(notebook) - - result = export_as_md(MarimoPath(notebook), flavor=flavor) - - assert result.download_filename == download_filename - assert expected in result.text - if absent: - assert absent not in result.text - - -@pytest.mark.parametrize( - ("filename", "flavor", "download_filename", "expected", "absent"), - [ - ("custom.qmd", None, "custom.qmd", "```{marimo .python", None), - ( - "custom.qmd", - "pymdown", - "custom.md", - "```python", - "```{marimo .python", - ), - (None, "qmd", "notebook.qmd", "```{marimo .python", None), - (None, "mystmd", "notebook.myst.md", "```{marimo} python", None), - ], -) -def test_export_as_md_uses_requested_filename_and_flavor( - temp_marimo_file: str, - tmp_path: Path, - filename: str | None, - flavor: MarkdownFlavorName | None, - download_filename: str, - expected: str, - absent: str | None, -) -> None: - output = str(tmp_path / filename) if filename else None - - result = export_as_md( - MarimoPath(temp_marimo_file), - flavor=flavor, - filename=output, - ) - - assert result.download_filename == download_filename - assert expected in result.text - if absent: - assert absent not in result.text diff --git a/tests/_server/files/test_os_file_system.py b/tests/_server/files/test_os_file_system.py index a1b4d34b16d..543fe908933 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-version:"), + ("qmd", "marimo-team/marimo"), ], ) def test_create_notebook( diff --git a/tests/_server/test_file_manager.py b/tests/_server/test_file_manager.py index 28dd89d2d6d..b836921cd4a 100644 --- a/tests/_server/test_file_manager.py +++ b/tests/_server/test_file_manager.py @@ -162,6 +162,8 @@ 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 "app = marimo.App()" not in contents diff --git a/tests/_session/state/test_session_view.py b/tests/_session/state/test_session_view.py index 11265eb5bd8..d23b9b8b3cb 100644 --- a/tests/_session/state/test_session_view.py +++ b/tests/_session/state/test_session_view.py @@ -1318,26 +1318,16 @@ def test_get_cell_console_outputs( def test_mark_auto_export(session_view: SessionView): assert session_view.needs_export("html") assert session_view.needs_export("md") - assert session_view.needs_md_export("pymdown") session_view.mark_auto_export_html() assert not session_view.needs_export("html") session_view.mark_auto_export_md() assert not session_view.needs_export("md") - assert not session_view.needs_md_export("pymdown") - assert session_view.needs_md_export("qmd") - - session_view.mark_auto_export_md("qmd") - assert not session_view.needs_md_export("pymdown") - assert not session_view.needs_md_export("qmd") - assert session_view.needs_md_export("mystmd") session_view._touch() assert session_view.needs_export("html") assert session_view.needs_export("md") - assert session_view.needs_md_export("pymdown") - assert session_view.needs_md_export("qmd") session_view.mark_auto_export_html() session_view.mark_auto_export_md() From fc0a8bc6583c836025663ad99fc3bcbc704d0c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 13:56:02 +0200 Subject: [PATCH 18/20] refactor: isolate markdown import dialects --- marimo/_convert/markdown/flavor/__init__.py | 20 ++- marimo/_convert/markdown/flavor/base.py | 27 ++- marimo/_convert/markdown/flavor/mystmd.py | 150 ++++++++++++++++ marimo/_convert/markdown/to_ir.py | 186 ++++---------------- tests/_server/files/test_os_file_system.py | 2 +- tests/_server/test_file_manager.py | 4 +- 6 files changed, 232 insertions(+), 157 deletions(-) diff --git a/marimo/_convert/markdown/flavor/__init__.py b/marimo/_convert/markdown/flavor/__init__.py index 507fd38ff58..83a771397e6 100644 --- a/marimo/_convert/markdown/flavor/__init__.py +++ b/marimo/_convert/markdown/flavor/__init__.py @@ -9,8 +9,12 @@ from marimo._convert.markdown.flavor.base import ( MarkdownFlavor, MarkdownFlavorName, + MarkdownImportDialect, +) +from marimo._convert.markdown.flavor.mystmd import ( + MystmdMarkdownFlavor, + _MystmdMarkdownImportDialect, ) -from marimo._convert.markdown.flavor.mystmd import MystmdMarkdownFlavor from marimo._convert.markdown.flavor.pymdown import PymdownMarkdownFlavor from marimo._convert.markdown.flavor.qmd import QmdMarkdownFlavor @@ -20,6 +24,7 @@ _PYMDOWN_MARKDOWN = PymdownMarkdownFlavor() _QMD_MARKDOWN = QmdMarkdownFlavor() _MYSTMD_MARKDOWN = MystmdMarkdownFlavor() +_MYSTMD_MARKDOWN_IMPORT = _MystmdMarkdownImportDialect() _MARKDOWN_FLAVORS: Mapping[MarkdownFlavorName, MarkdownFlavor] = ( MappingProxyType( { @@ -29,6 +34,9 @@ } ) ) +_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}) @@ -81,6 +89,16 @@ def normalize_markdown_flavor( 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: diff --git a/marimo/_convert/markdown/flavor/base.py b/marimo/_convert/markdown/flavor/base.py index bd2933c9e19..d0ae9bb4f86 100644 --- a/marimo/_convert/markdown/flavor/base.py +++ b/marimo/_convert/markdown/flavor/base.py @@ -2,8 +2,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar, Literal +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, ClassVar, Literal, Protocol if TYPE_CHECKING: from collections.abc import Iterator @@ -42,6 +42,29 @@ class MarkdownExportDocument: 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. diff --git a/marimo/_convert/markdown/flavor/mystmd.py b/marimo/_convert/markdown/flavor/mystmd.py index cb1cfcea937..601231b3efa 100644 --- a/marimo/_convert/markdown/flavor/mystmd.py +++ b/marimo/_convert/markdown/flavor/mystmd.py @@ -15,20 +15,36 @@ 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"} # marimo-specific metadata filtered before writing MyST frontmatter. _MARIMO_METADATA_KEYS = {"width"} +# MyST marimo executable directive headers. +_MARIMO_DIRECTIVE_HEADER_RE = re.compile( + r"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$" +) +# MyST marimo page-level configuration directive headers. +_MARIMO_CONFIG_HEADER_RE = re.compile( + r"^(?P<fence>`{3,})\{marimo-config\}\s*$" +) +# 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<type>[a-zA-Z0-9-]+)$\s" @@ -36,6 +52,61 @@ ) +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 any(_is_marimo_header(line) for line in text.splitlines()) + + 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: + normalized.append(lines[index]) + 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(lines[closing_index]) + index = closing_index + 1 + + return normalized + + class MystmdMarkdownFlavor(MarkdownFlavor): """Render marimo notebooks as mystmd markdown. @@ -164,3 +235,82 @@ def _uncomment_script_metadata(content: str) -> str: 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 _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_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/to_ir.py b/marimo/_convert/markdown/to_ir.py index 3781da02ea5..71c174fbeb9 100644 --- a/marimo/_convert/markdown/to_ir.py +++ b/marimo/_convert/markdown/to_ir.py @@ -34,10 +34,15 @@ 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 default_markdown_flavor +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 ( @@ -48,32 +53,12 @@ ) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence LOGGER = _loggers.marimo_logger() MARIMO_MD = "marimo-md" MARIMO_CODE = "marimo-code" -# mystmd directives are fenced code blocks with the directive name in braces, -# followed by directive arguments and optional `:key: value` option lines. -# Reference: https://mystmd.org/guide/directives -# -# marimo code blocks use the mystmd directive form: -# -# ```{marimo} python -# :hide-code: true -# -# print("hello") -# ``` -_MYSTMD_MARIMO_HEADER_RE = re.compile( - r"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$" -) -_MYSTMD_MARIMO_CONFIG_HEADER_RE = re.compile( - r"^(?P<fence>`{3,})\{marimo-config\}\s*$" -) -_MYSTMD_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$") -_MYSTMD_DIRECTIVE_CLASS = "mystmd-marimo" -_MYSTMD_CONFIG_KEYS = {"header", "pyproject"} ConvertKeys = Literal["marimo-ir"] @@ -103,77 +88,8 @@ def extract_attribs( return {} -def _is_mystmd_marimo_directive_header(line: str) -> bool: - return bool(_MYSTMD_MARIMO_HEADER_RE.match(line)) - - -def _is_mystmd_marimo_config_header(line: str) -> bool: - return bool(_MYSTMD_MARIMO_CONFIG_HEADER_RE.match(line)) - - -def _is_mystmd_marimo_header(line: str) -> bool: - return _is_mystmd_marimo_directive_header( - line - ) or _is_mystmd_marimo_config_header(line) - - -def _is_closing_fence(line: str, opening_fence: str) -> bool: - stripped = line.strip() - return len(stripped) >= len(opening_fence) and set(stripped) == {"`"} - - -def _is_preprocessed_mystmd_marimo_fence(line: str) -> bool: - return f".{_MYSTMD_DIRECTIVE_CLASS}" in line - - -def _extract_mystmd_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 = _MYSTMD_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 _extract_mystmd_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 _MYSTMD_CONFIG_KEYS and isinstance(value, str) - } - - def _is_code_tag(text: str) -> bool: head = text.split("\n")[0].strip() - if _is_mystmd_marimo_config_header(head): - return False legacy_format = bool(re.search(r"\{.*python.*\}", head)) legacy_format |= bool(re.search(r"\{.*sql.*\}", head)) if DependencyManager.new_superfences.has_required_version(quiet=True): @@ -184,12 +100,14 @@ def _is_code_tag(text: str) -> bool: def _get_language(text: str) -> str: header = text.split("\n").pop(0) - mystmd_match = re.match(r"^`{3,}\{marimo\}\s+(?P<language>\w+)", header) - if mystmd_match: - return str(mystmd_match.group("language")) 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" @@ -386,13 +304,14 @@ def __init__( self, *args: Any, output_format: ConvertKeys = "marimo-ir", - enable_mystmd: bool = False, + 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. @@ -403,9 +322,13 @@ def __init__( self.preprocessors.register( FrontMatterPreprocessor(self), "frontmatter", 100 ) - if enable_mystmd: + if import_dialects: self.preprocessors.register( - MystmdMarimoPreprocessor(self), "mystmd-marimo", 99 + MarkdownImportDialectPreprocessor( + self, import_dialects, import_context + ), + "markdown-import-dialects", + 99, ) fences_ext = SuperFencesCodeExtension() fences_ext.extendMarkdown(self) @@ -472,52 +395,25 @@ def run(self, lines: list[str]) -> list[str]: return doc.split("\n") -class MystmdMarimoPreprocessor(Preprocessor): - """Normalize mystmd marimo directive fences before SuperFences parses them.""" +class MarkdownImportDialectPreprocessor(Preprocessor): + """Normalize dialect-specific markdown before SuperFences parses it.""" - def __init__(self, md: MarimoMdParser): + 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]: - normalized: list[str] = [] - index = 0 - - while index < len(lines): - config_match = _MYSTMD_MARIMO_CONFIG_HEADER_RE.match(lines[index]) - if config_match is not None: - fence = config_match.group("fence") - closing_index = index + 1 - while closing_index < len(lines): - if _is_closing_fence(lines[closing_index], fence): - metadata = _extract_mystmd_config_metadata( - lines[index + 1 : closing_index] - ) - self.md.meta.update(metadata) - index = closing_index + 1 - break - closing_index += 1 - else: - normalized.extend(lines[index:]) - break - continue - - match = _MYSTMD_MARIMO_HEADER_RE.match(lines[index]) - if match is None: - normalized.append(lines[index]) - index += 1 - continue - - normalized.append( - "{fence}{language} {{.marimo .{directive_class}}}".format( - fence=match.group("fence"), - language=match.group("language") or "python", - directive_class=_MYSTMD_DIRECTIVE_CLASS, - ) - ) - index += 1 - - return normalized + for dialect in self.import_dialects: + lines = dialect.preprocess(lines, self.context) + self.md.meta.update(self.context.metadata) + return lines class SanitizeProcessor(Preprocessor): @@ -624,17 +520,9 @@ def add_paragraph() -> None: code_block = SubElement(parent, MARIMO_CODE) block_lines = code.split("\n") - body_lines = block_lines[1:-1] + code_block.text = "\n".join(block_lines[1:-1]) attribs = extract_attribs(block_lines[0]) - if _is_mystmd_marimo_directive_header( - block_lines[0] - ) or _is_preprocessed_mystmd_marimo_fence(block_lines[0]): - mystmd_options, body_lines = _extract_mystmd_directive_options( - body_lines - ) - attribs.update(mystmd_options) - code_block.text = "\n".join(body_lines) if attribs: code_block.attrib = attribs @@ -655,9 +543,7 @@ def convert_from_md_to_marimo_ir( ) notebook = MarimoMdParser( output_format="marimo-ir", - enable_mystmd=any( - _is_mystmd_marimo_header(line) for line in text.splitlines() - ), + import_dialects=_markdown_import_dialects(text, filepath), ).convert(text) assert isinstance(notebook, NotebookSerializationV1) return NotebookSerializationV1( 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..621bc948055 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,8 +161,7 @@ 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 From c3df9c1c3395040e700646164742208a570b08ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 15:58:10 +0200 Subject: [PATCH 19/20] fix: preserve MyST markdown round trips --- marimo/_convert/markdown/flavor/mystmd.py | 116 +++++++++++++++--- marimo/_session/notebook/serializer.py | 15 ++- .../markdown/test_markdown_conversion.py | 106 ++++++++++++++++ tests/_server/test_file_manager.py | 61 +++++++++ 4 files changed, 277 insertions(+), 21 deletions(-) diff --git a/marimo/_convert/markdown/flavor/mystmd.py b/marimo/_convert/markdown/flavor/mystmd.py index 601231b3efa..7f50cb12ad8 100644 --- a/marimo/_convert/markdown/flavor/mystmd.py +++ b/marimo/_convert/markdown/flavor/mystmd.py @@ -32,17 +32,21 @@ LOGGER = _loggers.marimo_logger() # Metadata emitted through the `{marimo-config}` directive. -_CONFIG_KEYS = {"header", "pyproject"} +_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"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$" + r"^[ ]{0,3}(?P<fence>`{3,})\{marimo\}" + r"(?:\s+(?P<language>\w+))?\s*$" ) # MyST marimo page-level configuration directive headers. _MARIMO_CONFIG_HEADER_RE = re.compile( - r"^(?P<fence>`{3,})\{marimo-config\}\s*$" + r"^[ ]{0,3}(?P<fence>`{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<fence>`{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. @@ -59,7 +63,9 @@ class _MystmdMarkdownImportDialect: def matches(self, text: str, filepath: str | None) -> bool: del filepath - return any(_is_marimo_header(line) for line in text.splitlines()) + return ( + _find_next_top_level_marimo_header(text.splitlines()) is not None + ) def preprocess( self, lines: list[str], context: MarkdownImportContext @@ -85,8 +91,20 @@ def preprocess( match = _MARIMO_DIRECTIVE_HEADER_RE.match(lines[index]) if match is None: - normalized.append(lines[index]) - index += 1 + 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( @@ -101,7 +119,7 @@ def preprocess( ) normalized.append(_canonical_code_fence_head(match, options)) normalized.extend(body_lines) - normalized.append(lines[closing_index]) + normalized.append(match.group("fence")) index = closing_index + 1 return normalized @@ -133,14 +151,8 @@ def render_markdown(self, block: MarkdownCellBlock) -> str: def render_code_cell(self, cell: CodeCellBlock) -> str: code_lines = cell.source.splitlines() - if not any(line.strip() for line in code_lines): - code_lines = [ - "pass" if cell.language == "python" else "-- empty cell" - ] code = "\n".join(code_lines) - guard = "```" - while guard in code: - guard += "`" + guard = _fence_guard_for(code) return "\n".join( [ @@ -191,21 +203,25 @@ def _mystmd_config( 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 [ - "```{marimo-config}", + f"{guard}{{marimo-config}}", "---", body, "---", - "```", + guard, "", ] @@ -244,6 +260,34 @@ def _is_marimo_header(line: str) -> bool: ) +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) == {"`"} @@ -291,6 +335,46 @@ def _canonical_code_fence_head( ) +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 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/tests/_convert/markdown/test_markdown_conversion.py b/tests/_convert/markdown/test_markdown_conversion.py index f909dfedc07..05d122957aa 100644 --- a/tests/_convert/markdown/test_markdown_conversion.py +++ b/tests/_convert/markdown/test_markdown_conversion.py @@ -235,6 +235,56 @@ def test_mystmd_marimo_config_directive_only() -> None: 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 @@ -257,6 +307,23 @@ def test_mystmd_marimo_config_keeps_indented_yaml_delimiters() -> None: ) +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}", @@ -311,6 +378,45 @@ def test_mystmd_exported_config_directive_round_trips() -> None: 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>"}', diff --git a/tests/_server/test_file_manager.py b/tests/_server/test_file_manager.py index 621bc948055..2ee14f82e3c 100644 --- a/tests/_server/test_file_manager.py +++ b/tests/_server/test_file_manager.py @@ -165,6 +165,67 @@ def test_rename_to_qmd(app_file_manager: AppFileManager) -> None: 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: From 321266aabd3f428ff7e7256b264fe4fc6cf7dd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= <dev.petergy@gmail.com> Date: Tue, 19 May 2026 15:58:19 +0200 Subject: [PATCH 20/20] fix: keep markdown export targets from retitling notebooks --- marimo/_cli/export/commands.py | 9 ++++-- marimo/_convert/markdown/from_ir.py | 7 ++++- marimo/_server/api/endpoints/export.py | 29 +++++++++++++++--- marimo/_server/export/__init__.py | 12 ++++++-- tests/_cli/test_cli_export.py | 30 +++++++++++++++++-- .../markdown/test_markdown_from_ir.py | 1 + tests/_server/api/endpoints/test_export.py | 23 ++++++++++++++ tests/_server/export/test_exporter.py | 24 +++++++++++++++ 8 files changed, 123 insertions(+), 12 deletions(-) diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 898edd5aae8..a14ecab459c 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -192,13 +192,14 @@ def _export_as_markdown( ) ir = replace(converter.ir, filename=path.short_name) - export_filename = filename or ir.filename or 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=export_filename, + filename=source_filename, flavor=markdown_flavor, ), download_filename=markdown_output_filename( @@ -392,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( diff --git a/marimo/_convert/markdown/from_ir.py b/marimo/_convert/markdown/from_ir.py index d4755fd3a11..951a629de3b 100644 --- a/marimo/_convert/markdown/from_ir.py +++ b/marimo/_convert/markdown/from_ir.py @@ -178,7 +178,12 @@ def _notebook_to_markdown_export_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/_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/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index 26dce7e83f2..afa853bee74 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -712,12 +712,15 @@ def test_export_markdown_with_flavor(temp_marimo_file: str) -> None: def test_export_markdown_infers_qmd_from_output( temp_marimo_file: str, tmp_path: Path ) -> None: - output = tmp_path / "notebook.qmd" + output = tmp_path / "output-target.qmd" p = _run_export("md", temp_marimo_file, "--output", str(output)) _assert_success(p) - assert "```{marimo .python" in output.read_text() + 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( @@ -730,6 +733,29 @@ def test_export_markdown_infers_mystmd_from_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 diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index de1be08cee8..32be1a2d9c1 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -21,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(): 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: