Skip to content

Commit e16344c

Browse files
committed
refactor: isolate markdown import dialects
1 parent 8a434ec commit e16344c

4 files changed

Lines changed: 228 additions & 153 deletions

File tree

marimo/_convert/markdown/flavor/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99
from marimo._convert.markdown.flavor.base import (
1010
MarkdownFlavor,
1111
MarkdownFlavorName,
12+
MarkdownImportDialect,
13+
)
14+
from marimo._convert.markdown.flavor.mystmd import (
15+
MystmdMarkdownFlavor,
16+
_MystmdMarkdownImportDialect,
1217
)
13-
from marimo._convert.markdown.flavor.mystmd import MystmdMarkdownFlavor
1418
from marimo._convert.markdown.flavor.pymdown import PymdownMarkdownFlavor
1519
from marimo._convert.markdown.flavor.qmd import QmdMarkdownFlavor
1620

@@ -20,6 +24,7 @@
2024
_PYMDOWN_MARKDOWN = PymdownMarkdownFlavor()
2125
_QMD_MARKDOWN = QmdMarkdownFlavor()
2226
_MYSTMD_MARKDOWN = MystmdMarkdownFlavor()
27+
_MYSTMD_MARKDOWN_IMPORT = _MystmdMarkdownImportDialect()
2328
_MARKDOWN_FLAVORS: Mapping[MarkdownFlavorName, MarkdownFlavor] = (
2429
MappingProxyType(
2530
{
@@ -29,6 +34,9 @@
2934
}
3035
)
3136
)
37+
_MARKDOWN_IMPORT_DIALECTS: Mapping[
38+
MarkdownFlavorName, MarkdownImportDialect
39+
] = MappingProxyType({_MYSTMD_MARKDOWN_IMPORT.name: _MYSTMD_MARKDOWN_IMPORT})
3240
# Filename inference handles target-specific markdown extensions.
3341
_MARKDOWN_FLAVORS_BY_EXTENSION: Mapping[str, MarkdownFlavor] = (
3442
MappingProxyType({".myst.md": _MYSTMD_MARKDOWN, ".qmd": _QMD_MARKDOWN})
@@ -81,6 +89,16 @@ def normalize_markdown_flavor(
8189
raise ValueError(f"Unsupported markdown flavor: {flavor!r}") from error
8290

8391

92+
def _markdown_import_dialects(
93+
text: str, filepath: str | None
94+
) -> tuple[MarkdownImportDialect, ...]:
95+
return tuple(
96+
dialect
97+
for dialect in _MARKDOWN_IMPORT_DIALECTS.values()
98+
if dialect.matches(text, filepath)
99+
)
100+
101+
84102
def _markdown_output_extension(
85103
flavor: MarkdownFlavor | MarkdownFlavorName,
86104
) -> str:

marimo/_convert/markdown/flavor/base.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from __future__ import annotations
33

44
from abc import ABC, abstractmethod
5-
from dataclasses import dataclass
6-
from typing import TYPE_CHECKING, ClassVar, Literal
5+
from dataclasses import dataclass, field
6+
from typing import TYPE_CHECKING, ClassVar, Literal, Protocol
77

88
if TYPE_CHECKING:
99
from collections.abc import Iterator
@@ -42,6 +42,29 @@ class MarkdownExportDocument:
4242
blocks: list[MarkdownExportBlock]
4343

4444

45+
@dataclass
46+
class MarkdownImportContext:
47+
"""Mutable state shared by markdown import dialects."""
48+
49+
metadata: dict[str, str] = field(default_factory=dict)
50+
51+
52+
class MarkdownImportDialect(Protocol):
53+
"""Source markdown syntax adapter for the canonical importer."""
54+
55+
name: MarkdownFlavorName
56+
57+
def matches(self, text: str, filepath: str | None) -> bool:
58+
"""Return whether this dialect should preprocess the markdown."""
59+
...
60+
61+
def preprocess(
62+
self, lines: list[str], context: MarkdownImportContext
63+
) -> list[str]:
64+
"""Normalize source markdown before the canonical importer runs."""
65+
...
66+
67+
4568
class MarkdownFlavor(ABC):
4669
"""Markdown-family output flavor.
4770

marimo/_convert/markdown/flavor/mystmd.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,96 @@
1515
import re
1616
from typing import TYPE_CHECKING
1717

18+
from marimo import _loggers
1819
from marimo._convert.markdown.flavor.base import (
1920
CodeCellBlock,
2021
MarkdownCellBlock,
2122
MarkdownExportDocument,
2223
MarkdownFlavor,
24+
MarkdownFlavorName,
25+
MarkdownImportContext,
26+
_escape_attribute,
2327
)
2428

2529
if TYPE_CHECKING:
2630
from collections.abc import Mapping
2731

32+
LOGGER = _loggers.marimo_logger()
33+
2834
# Metadata emitted through the `{marimo-config}` directive.
2935
_CONFIG_KEYS = {"header", "pyproject"}
3036
# marimo-specific metadata filtered before writing MyST frontmatter.
3137
_MARIMO_METADATA_KEYS = {"width"}
38+
# MyST marimo executable directive headers.
39+
_MARIMO_DIRECTIVE_HEADER_RE = re.compile(
40+
r"^(?P<fence>`{3,})\{marimo\}(?:\s+(?P<language>\w+))?\s*$"
41+
)
42+
# MyST marimo page-level configuration directive headers.
43+
_MARIMO_CONFIG_HEADER_RE = re.compile(
44+
r"^(?P<fence>`{3,})\{marimo-config\}\s*$"
45+
)
46+
# MyST directive options, e.g. `:hide-code: true`.
47+
_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$")
3248
# PEP 723 script metadata blocks embedded in exported notebook headers.
3349
_SCRIPT_METADATA_RE = re.compile(
3450
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s"
3551
r"(?P<content>(^#(| .*)$\s)+)^# ///$"
3652
)
3753

3854

55+
class _MystmdMarkdownImportDialect:
56+
name: MarkdownFlavorName = "mystmd"
57+
58+
def matches(self, text: str, filepath: str | None) -> bool:
59+
del filepath
60+
return any(_is_marimo_header(line) for line in text.splitlines())
61+
62+
def preprocess(
63+
self, lines: list[str], context: MarkdownImportContext
64+
) -> list[str]:
65+
normalized: list[str] = []
66+
index = 0
67+
68+
while index < len(lines):
69+
config_match = _MARIMO_CONFIG_HEADER_RE.match(lines[index])
70+
if config_match is not None:
71+
closing_index = _find_closing_fence(
72+
lines, index + 1, config_match.group("fence")
73+
)
74+
if closing_index is None:
75+
normalized.extend(lines[index:])
76+
break
77+
78+
context.metadata.update(
79+
_extract_config_metadata(lines[index + 1 : closing_index])
80+
)
81+
index = closing_index + 1
82+
continue
83+
84+
match = _MARIMO_DIRECTIVE_HEADER_RE.match(lines[index])
85+
if match is None:
86+
normalized.append(lines[index])
87+
index += 1
88+
continue
89+
90+
closing_index = _find_closing_fence(
91+
lines, index + 1, match.group("fence")
92+
)
93+
if closing_index is None:
94+
normalized.extend(lines[index:])
95+
break
96+
97+
options, body_lines = _extract_directive_options(
98+
lines[index + 1 : closing_index]
99+
)
100+
normalized.append(_canonical_code_fence_head(match, options))
101+
normalized.extend(body_lines)
102+
normalized.append(lines[closing_index])
103+
index = closing_index + 1
104+
105+
return normalized
106+
107+
39108
class MystmdMarkdownFlavor(MarkdownFlavor):
40109
"""Render marimo notebooks as mystmd markdown.
41110
@@ -164,3 +233,82 @@ def _uncomment_script_metadata(content: str) -> str:
164233

165234
def _mystmd_option_name(key: str) -> str:
166235
return key.replace("_", "-")
236+
237+
238+
def _is_marimo_header(line: str) -> bool:
239+
return bool(
240+
_MARIMO_DIRECTIVE_HEADER_RE.match(line)
241+
or _MARIMO_CONFIG_HEADER_RE.match(line)
242+
)
243+
244+
245+
def _is_closing_fence(line: str, opening_fence: str) -> bool:
246+
stripped = line.strip()
247+
return len(stripped) >= len(opening_fence) and set(stripped) == {"`"}
248+
249+
250+
def _find_closing_fence(
251+
lines: list[str], start: int, opening_fence: str
252+
) -> int | None:
253+
for index in range(start, len(lines)):
254+
if _is_closing_fence(lines[index], opening_fence):
255+
return index
256+
return None
257+
258+
259+
def _extract_directive_options(
260+
lines: list[str],
261+
) -> tuple[dict[str, str], list[str]]:
262+
options: dict[str, str] = {}
263+
body_start = 0
264+
265+
for index, line in enumerate(lines):
266+
match = _DIRECTIVE_OPTION_RE.match(line)
267+
if match is None:
268+
break
269+
options[match.group(1).replace("-", "_")] = match.group(2) or "true"
270+
body_start = index + 1
271+
272+
if body_start and body_start < len(lines) and lines[body_start] == "":
273+
body_start += 1
274+
275+
return options, lines[body_start:]
276+
277+
278+
def _canonical_code_fence_head(
279+
match: re.Match[str], options: dict[str, str]
280+
) -> str:
281+
attribute_str = "".join(
282+
f' {key}="{_escape_attribute(value)}"'
283+
for key, value in options.items()
284+
)
285+
return "{fence}{language} {{.marimo{attributes}}}".format(
286+
fence=match.group("fence"),
287+
language=match.group("language") or "python",
288+
attributes=attribute_str,
289+
)
290+
291+
292+
def _extract_config_metadata(lines: list[str]) -> dict[str, str]:
293+
from marimo._utils import yaml
294+
295+
if lines and lines[0] == "---":
296+
for index, line in enumerate(lines[1:], start=1):
297+
if line == "---":
298+
lines = lines[1:index]
299+
break
300+
301+
try:
302+
metadata = yaml.load("\n".join(lines))
303+
except yaml.YAMLError:
304+
LOGGER.warning("Error parsing marimo-config YAML. Ignoring config.")
305+
return {}
306+
307+
if not isinstance(metadata, dict):
308+
return {}
309+
310+
return {
311+
key: value
312+
for key, value in metadata.items()
313+
if key in _CONFIG_KEYS and isinstance(value, str)
314+
}

0 commit comments

Comments
 (0)