Skip to content

Commit fe126dc

Browse files
committed
fix: preserve header
1 parent 3002498 commit fe126dc

File tree

5 files changed

+110
-16
lines changed

5 files changed

+110
-16
lines changed

marimo/_ast/codegen.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,15 +490,46 @@ def generate_filecontents_from_ir(ir: NotebookSerializationV1) -> str:
490490
# Markdown frontmatter may contain non-config metadata (e.g., author,
491491
# description). Suppress warnings for unrecognized keys from markdown.
492492
silent = MarimoPath(ir.filename).is_markdown() if ir.filename else False
493+
header_comments = _extract_header_comments(ir)
493494
return generate_filecontents(
494495
codes=[cell.code for cell in ir.cells],
495496
names=[cell.name for cell in ir.cells],
496497
cell_configs=[CellConfig.from_dict(cell.options) for cell in ir.cells],
497498
config=_AppConfig.from_untrusted_dict(ir.app.options, silent=silent),
498-
header_comments=ir.header.value if ir.header else None,
499+
header_comments=header_comments,
499500
)
500501

501502

503+
def _extract_header_comments(ir: NotebookSerializationV1) -> Optional[str]:
504+
"""Extract script preamble from header.
505+
506+
For markdown notebooks, Header.value may contain YAML-encoded frontmatter
507+
metadata (with a 'header' key for the script preamble, and/or a
508+
'pyproject' key for inline dependencies). For Python notebooks, it's the
509+
raw script preamble.
510+
"""
511+
if not ir.header or not ir.header.value:
512+
return None
513+
from marimo._utils import yaml
514+
515+
try:
516+
parsed = yaml.load(ir.header.value)
517+
if isinstance(parsed, dict):
518+
# YAML dict means frontmatter metadata; extract script preamble
519+
header = parsed.get("header", None)
520+
pyproject = parsed.get("pyproject", None)
521+
if header:
522+
return str(header)
523+
if pyproject:
524+
from marimo._utils.scripts import wrap_script_metadata
525+
526+
return wrap_script_metadata(pyproject)
527+
return None
528+
except (yaml.YAMLError, AssertionError):
529+
pass
530+
return ir.header.value
531+
532+
502533
def generate_filecontents(
503534
codes: list[str],
504535
names: list[str],

marimo/_convert/markdown/from_ir.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,18 @@ def convert_from_ir_to_markdown(
6262
}
6363
)
6464

65-
# Add header from notebook if present
65+
# Recover frontmatter metadata from header
6666
if notebook.header and notebook.header.value:
67-
metadata["header"] = notebook.header.value.strip()
67+
try:
68+
frontmatter = yaml.load(notebook.header.value)
69+
if isinstance(frontmatter, dict):
70+
# Insert metadata before config so config takes precedence
71+
_recovered = dict(frontmatter)
72+
_recovered.update(metadata)
73+
metadata = _recovered
74+
except (yaml.YAMLError, AssertionError):
75+
# Not valid YAML dict — treat as script preamble
76+
metadata["header"] = notebook.header.value.strip()
6877

6978
# Add the expected qmd filter to the metadata.
7079
is_qmd = filename.endswith(".qmd")

marimo/_convert/markdown/to_ir.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,12 @@ def strip(self) -> T:
195195

196196

197197
def _tree_to_ir(root: Element) -> SafeWrap[NotebookSerializationV1]:
198+
from marimo._ast.app_config import _AppConfig
199+
from marimo._utils import yaml
200+
from marimo._utils.scripts import wrap_script_metadata
201+
198202
app_config = app_config_from_root(root)
203+
config_only = _AppConfig.sanitize(app_config)
199204

200205
sources: list[str] = []
201206
names: list[str] = []
@@ -208,14 +213,28 @@ def _tree_to_ir(root: Element) -> SafeWrap[NotebookSerializationV1]:
208213
)
209214
sources.append(get_source_from_tag(child))
210215

211-
from marimo._utils.scripts import wrap_script_metadata
212-
213-
header = root.get("header", None)
216+
header_str = root.get("header", None)
214217
pyproject = root.get("pyproject", None)
215-
if pyproject and not header:
216-
header = wrap_script_metadata(pyproject)
218+
219+
# Collect non-config frontmatter metadata
220+
frontmatter = {k: v for k, v in app_config.items() if k not in config_only}
221+
if pyproject:
222+
frontmatter["pyproject"] = pyproject
223+
if header_str:
224+
frontmatter["header"] = header_str
225+
226+
# Build header: frontmatter YAML for md, or script preamble
227+
if frontmatter:
228+
header_value = yaml.dump(frontmatter, sort_keys=False)
229+
elif pyproject and not header_str:
230+
header_value = wrap_script_metadata(pyproject)
231+
elif header_str:
232+
header_value = header_str
233+
else:
234+
header_value = None
235+
217236
notebook = NotebookSerializationV1(
218-
app=AppInstantiation(options=app_config),
237+
app=AppInstantiation(options=config_only),
219238
cells=[
220239
CellDef(
221240
name=name,
@@ -224,7 +243,7 @@ def _tree_to_ir(root: Element) -> SafeWrap[NotebookSerializationV1]:
224243
)
225244
for name, source, config in zip(names, sources, cell_config)
226245
],
227-
header=Header(value=header) if header else None,
246+
header=Header(value=header_value) if header_value else None,
228247
)
229248
return SafeWrap(notebook)
230249

marimo/_session/notebook/serializer.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,21 @@ def deserialize(
102102
return convert_from_md_to_marimo_ir(content, filepath=filepath)
103103

104104
def extract_header(self, path: Path) -> Optional[str]:
105-
"""Extract YAML frontmatter from Markdown file."""
106-
from marimo._utils.inline_script_metadata import (
107-
get_headers_from_markdown,
108-
)
105+
"""Extract full frontmatter metadata from Markdown file as YAML.
106+
107+
Unlike Python files where only the script preamble matters, markdown
108+
frontmatter can carry arbitrary metadata (author, description, tags,
109+
etc.) that must survive through the save lifecycle. Return the full
110+
frontmatter as YAML so _save_file() preserves it all.
111+
"""
112+
from marimo._convert.markdown.to_ir import extract_frontmatter
113+
from marimo._utils import yaml
109114

110115
markdown = path.read_text(encoding="utf-8")
111-
headers = get_headers_from_markdown(markdown)
112-
return headers.get("header", None) or headers.get("pyproject", None)
116+
frontmatter, _ = extract_frontmatter(markdown)
117+
if not frontmatter:
118+
return None
119+
return yaml.dump(frontmatter, sort_keys=False)
113120

114121

115122
# Default format handlers

tests/_convert/markdown/test_markdown_conversion.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,34 @@ def test_markdown_just_frontmatter() -> None:
172172
assert app.cell_manager.cell_data_at(ids[0]).code == ""
173173

174174

175+
def test_markdown_frontmatter_metadata_roundtrip() -> None:
176+
"""Frontmatter metadata should survive md -> IR -> md roundtrip."""
177+
script = dedent(
178+
remove_empty_lines(
179+
"""
180+
---
181+
title: "My Title"
182+
author: "Marimo Team"
183+
description: "A notebook description"
184+
---
185+
186+
```python {.marimo}
187+
x = 1
188+
```
189+
"""
190+
)
191+
)
192+
193+
notebook_ir = convert_from_md_to_marimo_ir(script)
194+
roundtripped = MarimoConvert.from_ir(notebook_ir).to_markdown()
195+
assert "author: Marimo Team" in roundtripped
196+
# Description is preserved but YAML may use folded style (>-)
197+
from marimo._convert.markdown.to_ir import extract_frontmatter
198+
199+
meta, _ = extract_frontmatter(roundtripped)
200+
assert meta["description"] == "A notebook description"
201+
202+
175203
@pytest.mark.requires("duckdb")
176204
def test_markdown_with_sql() -> None:
177205
script = dedent(

0 commit comments

Comments
 (0)