Repository: marimo-team/marimo
Pull request: feat: enables sandboxing for markdown files
## 📝 Summary

 - preserves unknown frontmatter entries (e.g. `description`, `tag`, or `author`)
 - saves header values in `header` entry (fixes #2369)
 - Allows sand boxing from `header` entries OR a barebones `sandbox` entry
 - Conversion to qmd adds the needed quarto filter (fixes #4680)

needs:
 - maybe some cleanup?
 - tests:
   - [x] Conversion logic
   - [x] Sandboxing logic

Problem statement:
Preserved shebangs / header comments in marimo md documents
### Description

I think with the new sandbox change, editing does not strip the shebangs- which is great. Now this works out of the box, and is very nice:

`cat notebook.py`
```python
#!/usr/bin/env -S marimo export html --no-include-code
import marimo
# ...
```

`chmod +x notebook.py`

`./notebook.py`

---

You can push this further and can now create an environment with nix:

```python
#! /usr/bin/env nix
#! nix shell --impure --expr ``
#! nix with (import (builtins.getFlake ''nixpkgs'') {});
#! nix    let
#! nix        venv = python311.withPackages (
#! nix            ps: with ps; [ marimo matplotlib numpy ]
#! nix        );
#! nix    in pkgs.buildEnv {
#! nix        name = "marimo-env";
#! nix        # NB adding ffmpeg, openmpi and gcc as a nix shell dep.
#! nix        paths=[venv ffmpeg gcc-unwrapped.lib openmpi];
#! nix    } 
#! nix ``
#! nix --command marimo run

import marimo
# ...
```

Note, you can even combine this with sandbox to pin your python and UV to nix:

```python
#! /usr/bin/env nix
#! nix shell --impure --expr ``
#! nix with (import (builtins.getFlake ''nixpkgs'') {});
#! nix    python311.withPackages (
#! nix            ps: with ps; [ marimo uv setuptools ]
#! nix    )
#! nix ``
#! nix --command marimo edit --sandbox
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "numpy",
# ]
# ///

import marimo
# ...
```

In theory, you could even have nix build or launch an actual container

### Suggested solution

This already works, so could just be an undocumented feature. However, I'm thinking of preserving `#! nix` and `script` sandboxes in the markdown format (e.g):

```python
---
title: Notebook
marimo-version: 0.0.0
width: medium
nix: |
    with import <nixpkgs> {};
    python311.withPackages (
        ps: with ps; [ marimo uv jaxlibWithCuda ]
    )
---   
```

(and or `script: |`) So having the documentation for the normal case would be nice too.

I am also very conscious that I am full on with the nix koolaid :grimacing:, so documenting and then potentially having to retain support might not be appealing.

### Alternative

_No response_

### Additional context

_No response_

Export as qmd
### Description

same as md but should insert the marimo filter
also ensure that other headers are preserved

### Suggested solution

-

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:
could it be `header_comment` or just `header` in the markdown? since it would be nice to preserve these comments and since both `script` and `nix` are slightly different in their comment structuer
That would be nice because there might be other header comments that could be leveraged.
Arguments for having sections:
 - Most / single hashbangs aren't useful since `.md` files aren't runnable
 - writing the env becomes more cumbersome and prone to error
 - In the header form (even with `#` stripped out), the block would still have to be parsed to be usable and require sandbox / nix particular logic for the consumer
> the block would still have to be parsed to be usable and require sandbox / nix particular logic for the consumer

Why does it? Why can't we just not touch the header and leave it as is? I don't think marimo can/should be opinionated on how to construct the env, especially since there isn't _one way_.
I think there should be parity with the python format. So even if there isn't a `nix` section, maybe there should be a `sandbox` section that extracts the relevant `# /// script` section. Even though there's not one way to bootstrap an env, the marimo seems to be embracing the PEP specification 
Could we not go a level higher, and instead of preserving `# /// script`, we just preserve the whole comment as-in without any modification?

```md
---
title: Notebook
marimo-version: 0.0.0
width: medium
comment: |
    #! /usr/bin/env nix
    #! nix shell --impure --expr ``
    #! nix with (import (builtins.getFlake ''nixpkgs'') {});
    #! nix    python311.withPackages (
    #! nix            ps: with ps; [ marimo uv setuptools ]
    #! nix    )
    #! nix ``
    #! nix --command marimo edit --sandbox
    # /// script
    # requires-python = ">=3.11"
    # dependencies = [
    #     "numpy",
    # ]
    # ///
---   
```
Yeah, this works, but seems a little bulky when marimo does offer first class `--sandbox` support

```md
---
title: Notebook
marimo-version: 0.0.0
width: medium
header: |
    #! /usr/bin/env nix
    #! nix shell --impure --expr ``
    #! nix with (import (builtins.getFlake ''nixpkgs'') {});
    #! nix    python311.withPackages (
    #! nix            ps: with ps; [ marimo uv setuptools ]
    #! nix    )
    #! nix ``
    #! nix --command marimo edit --sandbox
    """
    Maybe preserve doc strings too
    """
sandbox: |
    requires-python = ">=3.11"
    dependencies = [
        "numpy",
    ]
---
```

---

Why add the prefix overhead for users?

Related, https://github.com/marimo-team/marimo/issues/1451 may only offer limited backwards compatibility
So there might be an opportunity to play with the md format more
> Why add the prefix overhead for users?

I dont see this as overhead but rather transparency. It's very WYSIWYG.

Also we don't need to track which prefix types there are `#` vs `# script` vs`#!` and all the possible executable types.

Commit message: fix: for existing files

Patch to review:
diff --git a/marimo/_ast/app_config.py b/marimo/_ast/app_config.py
index 4c4ba8a3a7f..b08442be13d 100644
--- a/marimo/_ast/app_config.py
+++ b/marimo/_ast/app_config.py
@@ -38,7 +38,9 @@ class _AppConfig:
     sql_output: SqlOutputType = "auto"
 
     @staticmethod
-    def from_untrusted_dict(updates: dict[str, Any]) -> "_AppConfig":
+    def from_untrusted_dict(
+        updates: dict[str, Any], silent: bool = False
+    ) -> "_AppConfig":
         # Certain flags are useful to pass to App for construction, but
         # shouldn't make it into the config. (e.g. the _filename flag is
         # internal)
@@ -48,9 +50,10 @@ def from_untrusted_dict(updates: dict[str, Any]) -> "_AppConfig":
             if hasattr(config, key):
                 config.__setattr__(key, updates[key])
             elif key not in other_allowed:
-                LOGGER.warning(
-                    f"Unrecognized key '{key}' in app config. Ignoring."
-                )
+                if not silent:
+                    LOGGER.warning(
+                        f"Unrecognized key '{key}' in app config. Ignoring."
+                    )
         return config
 
     def asdict(self) -> dict[str, Any]:
diff --git a/marimo/_ast/codegen.py b/marimo/_ast/codegen.py
index 7996f6a30cb..6f8a1e95fe8 100644
--- a/marimo/_ast/codegen.py
+++ b/marimo/_ast/codegen.py
@@ -7,7 +7,7 @@
 import re
 import sys
 import textwrap
-from typing import Any, Literal, Optional, cast
+from typing import TYPE_CHECKING, Any, Literal, Optional, cast
 
 from marimo import __version__
 from marimo._ast.app_config import _AppConfig
@@ -19,6 +19,9 @@
 from marimo._ast.visitor import Name
 from marimo._types.ids import CellId_t
 
+if TYPE_CHECKING:
+    from pathlib import Path
+
 if sys.version_info < (3, 10):
     from typing_extensions import TypeAlias
 else:
@@ -380,7 +383,7 @@ def is_multiline_comment(node: ast.stmt) -> bool:
     return False
 
 
-def get_header_comments(filename: str) -> Optional[str]:
+def get_header_comments(filename: str | Path) -> Optional[str]:
     """Gets the header comments from a file. Returns
     None if the file does not exist or the header is
     invalid, which is determined by:
diff --git a/marimo/_ast/load.py b/marimo/_ast/load.py
index 3593a16f511..9ea99047097 100644
--- a/marimo/_ast/load.py
+++ b/marimo/_ast/load.py
@@ -93,7 +93,7 @@ def load_app(filename: Optional[str]) -> Optional[App]:
     if filename is None:
         return None
 
-    if filename.endswith(".md"):
+    if filename.endswith(".md") or filename.endswith(".qmd"):
         contents = _maybe_contents(filename)
         if not contents:
             return None
diff --git a/marimo/_cli/convert/markdown.py b/marimo/_cli/convert/markdown.py
index f5851d783a0..43d4b9c6ef9 100644
--- a/marimo/_cli/convert/markdown.py
+++ b/marimo/_cli/convert/markdown.py
@@ -39,16 +39,6 @@
 
 ConvertKeys = Union[Literal["marimo"], Literal["marimo-app"]]
 
-# Regex captures loose yaml for frontmatter
-# Should match the following:
-# ---
-# title: "Title"
-# whatever
-# ---
-YAML_FRONT_MATTER_REGEX = re.compile(
-    r"^---\s*\n(.*?\n?)(?:---)\s*\n", re.UNICODE | re.DOTALL
-)
-
 
 def backwards_compatible_sanitization(line: str) -> str:
     return line
@@ -132,7 +122,7 @@ def app_config_from_root(root: Element) -> _AppConfig:
     # Remove values particular to markdown saves.
     config.pop("marimo-version", None)
 
-    return _AppConfig.from_untrusted_dict(config)
+    return _AppConfig.from_untrusted_dict(config, silent=True)
 
 
 def get_source_from_tag(tag: Element) -> str:
@@ -232,11 +222,16 @@ def _tree_to_app(root: Element) -> str:
         )
         sources.append(get_source_from_tag(child))
 
+    header = root.get("header", None)
+    sandbox = root.get("sandbox", None)
+    if sandbox and not header:
+        header = "\n# ".join(["# ///script", *sandbox.splitlines(), "///"])
     return codegen.generate_filecontents(
         sources,
         names,
         cell_config,
         config=app_config,
+        header_comments=header,
     )
 
 
@@ -289,6 +284,7 @@ class MarimoParser(IdentityParser):
 
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
+        self.meta = {}
         # 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.
@@ -349,34 +345,18 @@ class FrontMatterPreprocessor(Preprocessor):
 
     def __init__(self, md: MarimoParser):
         super().__init__(md)
-        self.md = md
-        self.md.meta = {}
+        self.md: MarimoParser = md
 
     def run(self, lines: list[str]) -> list[str]:
-        import yaml
-
-        # CSafeLoader is faster than SafeLoader.
-        try:
-            from yaml import CSafeLoader as SafeLoader
-        except ImportError:
-            from yaml import SafeLoader  # type: ignore[assignment]
-
         if not lines:
             return lines
 
         doc = "\n".join(lines)
-        result = YAML_FRONT_MATTER_REGEX.match(doc)
-
-        if result:
-            yaml_content = result.group(1)
-            try:
-                meta = yaml.load(yaml_content, SafeLoader)
-                if isinstance(meta, dict):
-                    self.md.meta = meta  # type: ignore[attr-defined]
-                doc = doc[result.end() :].lstrip("\n")
-            # If there's an error in parsing YAML, ignore the meta and proceed.
-            except yaml.YAMLError as e:
-                raise e
+
+        meta, doc = extract_frontmatter(doc)
+        if isinstance(meta, dict):
+            self.md.meta.update(meta)
+
         return doc.split("\n")
 
 
@@ -439,7 +419,8 @@ def test(*_args: Any) -> bool:
 
     def run(self, parent: Element, blocks: list[str]) -> None:
         # Copy app metadata to the parent element.
-        for key, value in self.parser.md.meta.items():  # type: ignore[attr-defined]
+        assert isinstance(self.parser.md, MarimoParser)
+        for key, value in self.parser.md.meta.items():
             if isinstance(value, str):
                 parent.set(key, value)
 
@@ -508,11 +489,30 @@ def convert_from_md_to_app(text: str) -> App:
 
 
 def convert_from_md(text: str) -> str:
-    return MarimoParser(output_format="marimo").convert(text)  # type: ignore[arg-type]
+    return MarimoParser(output_format="marimo").convert(text)
+
+
+def extract_frontmatter(text: str) -> tuple[dict[str, str], str]:
+    from marimo._utils import yaml
+
+    result = yaml.YAML_FRONT_MATTER_REGEX.match(text)
+
+    if result:
+        yaml_content = result.group(1)
+        body = text[result.end() :].lstrip("\n")
+        try:
+            return yaml.load(yaml_content), body
+        # If there's an error in parsing YAML, ignore the meta and proceed.
+        except yaml.YAMLError:
+            LOGGER.warning(
+                "Error parsing frontmatter YAML. Ignoring frontmatter."
+            )
+            return {}, body
+    return {}, text
 
 
 def sanitize_markdown(text: str) -> str:
-    return SanitizeParser(output_format="identity").convert(text)  # type: ignore[arg-type]
+    return SanitizeParser(output_format="identity").convert(text)
 
 
 def is_sanitized_markdown(text: str) -> bool:
diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py
index 25afb999f26..6df45b8e94d 100644
--- a/marimo/_cli/export/commands.py
+++ b/marimo/_cli/export/commands.py
@@ -331,7 +331,7 @@ def md(
         return
 
     def export_callback(file_path: MarimoPath) -> ExportResult:
-        return export_as_md(file_path)
+        return export_as_md(file_path, new_filename=output)
 
     return watch_and_export(MarimoPath(name), output, watch, export_callback)
 
diff --git a/marimo/_cli/sandbox.py b/marimo/_cli/sandbox.py
index 788c1f01eaf..14410065171 100644
--- a/marimo/_cli/sandbox.py
+++ b/marimo/_cli/sandbox.py
@@ -85,6 +85,29 @@ def requirements_txt_lines(self) -> list[str]:
             return []
 
 
+def get_headers_from_markdown(contents: str) -> dict[str, str]:
+    from marimo._cli.convert.markdown import extract_frontmatter
+
+    frontmatter, _ = extract_frontmatter(contents)
+    return get_headers_from_frontmatter(frontmatter)
+
+
+def get_headers_from_frontmatter(
+    frontmatter: dict[str, Any],
+) -> dict[str, str]:
+    headers = {"sandbox": "", "header": ""}
+
+    sandbox = frontmatter.get("sandbox", "")
+    if sandbox:
+        if not sandbox.startswith("#"):
+            sandbox = "\n# ".join(
+                [r"# /// script", *sandbox.splitlines(), r"///"]
+            )
+        headers["sandbox"] = sandbox
+    headers["header"] = frontmatter.get("header", "")
+    return headers
+
+
 def _pyproject_toml_to_requirements_txt(
     pyproject: dict[str, Any],
 ) -> list[str]:
@@ -158,7 +181,26 @@ def _pyproject_toml_to_requirements_txt(
 def _get_pyproject_from_filename(name: str) -> dict[str, Any] | None:
     try:
         contents, _ = FileContentReader().read_file(name)
-        return read_pyproject_from_script(contents)
+        if name.endswith(".py"):
+            return read_pyproject_from_script(contents)
+
+        if not (name.endswith(".md") or name.endswith(".qmd")):
+            raise ValueError(
+                f"Unsupported file type: {name}. Only .py and .md files are supported."
+            )
+
+        headers = get_headers_from_markdown(contents)
+        header = headers["sandbox"]
+        if not header:
+            header = headers["header"]
+        elif headers["header"]:
+            pyproject = PyProjectReader.from_script(headers["header"])
+            if pyproject.dependencies or pyproject.python_version:
+                LOGGER.warning(
+                    "Both header and sandbox provide dependencies. "
+                    "Preferring sandbox."
+                )
+        return read_pyproject_from_script(header)
     except FileNotFoundError:
         return None
     except Exception:
diff --git a/marimo/_runtime/packages/pypi_package_manager.py b/marimo/_runtime/packages/pypi_package_manager.py
index 3de192713f0..2d0084be279 100644
--- a/marimo/_runtime/packages/pypi_package_manager.py
+++ b/marimo/_runtime/packages/pypi_package_manager.py
@@ -2,8 +2,10 @@
 from __future__ import annotations
 
 import json
+import os
 import subprocess
 import sys
+import tempfile
 from typing import Optional
 
 from marimo._runtime.packages.module_name_to_pypi_name import (
@@ -186,6 +188,79 @@ def _maybe_add_version(package: str) -> str:
             if _is_installed(im)
         ]
 
+        if filepath.endswith(".md") or filepath.endswith(".qmd"):
+            # md and qmd require writing to a faux python file first.
+            return self._process_md_changes(
+                filepath, packages_to_add, packages_to_remove
+            )
+        return self._process_changes(
+            filepath, packages_to_add, packages_to_remove
+        )
+
+    def _process_md_changes(
+        self,
+        filepath: str,
+        packages_to_add: list[str],
+        packages_to_remove: list[str],
+    ) -> None:
+        from marimo._cli.convert.markdown import extract_frontmatter
+        from marimo._cli.sandbox import get_headers_from_frontmatter
+        from marimo._utils import yaml
+
+        # Get script metadata
+        with open(filepath) as f:
+            frontmatter, body = extract_frontmatter(f.read())
+        headers = get_headers_from_frontmatter(frontmatter)
+        sandbox = bool(headers.get("sandbox", ""))
+        header = (
+            headers.get("sandbox", "")
+            if sandbox
+            else headers.get("header", "")
+        )
+        sandbox = sandbox or not bool(header)
+
+        # Write out and process the header
+        with tempfile.NamedTemporaryFile(
+            mode="w", delete=False, suffix=".py"
+        ) as temp_file:
+            temp_file.write(header)
+            temp_file.flush()
+        # Have UV modify it
+        self._process_changes(
+            temp_file.name,
+            packages_to_add,
+            packages_to_remove,
+        )
+        with open(temp_file.name) as f:
+            header = f.read()
+        # Clean up the temporary file
+        os.unlink(temp_file.name)
+
+        # Write back the changes to the original file
+        if sandbox:
+            # Strip '# '
+            # and leading/trailing ///
+            header = "\n".join(
+                [line[2:] for line in header.strip().splitlines()[1:-1]]
+            )
+            frontmatter["sandbox"] = header
+        else:
+            frontmatter["header"] = header
+
+        header = yaml.marimo_compat_dump(
+            frontmatter,
+            sort_keys=False,
+        )
+        document = ["---", header.strip(), "---", body]
+        with open(filepath, "w") as f:
+            f.write("\n".join(document))
+
+    def _process_changes(
+        self,
+        filepath: str,
+        packages_to_add: list[str],
+        packages_to_remove: list[str],
+    ) -> None:
         if packages_to_add:
             self.run(
                 ["uv", "--quiet", "add", "--script", filepath]
diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py
index a66cdea71c5..4b9027762a1 100644
--- a/marimo/_server/export/__init__.py
+++ b/marimo/_server/export/__init__.py
@@ -5,7 +5,7 @@
 import os
 import sys
 from dataclasses import dataclass
-from typing import Any, Callable, Literal, Optional, Union, cast
+from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union, cast
 
 from marimo._cli.print import echo
 from marimo._config.config import RuntimeConfig
@@ -29,6 +29,9 @@
 from marimo._utils.marimo_path import MarimoPath
 from marimo._utils.parse_dataclass import parse_raw
 
+if TYPE_CHECKING:
+    from pathlib import Path
+
 
 @dataclass
 class ExportResult:
@@ -55,13 +58,17 @@ def export_as_script(
 
 def export_as_md(
     path: MarimoPath,
+    new_filename: Optional[Path] = None,
 ) -> ExportResult:
     file_router = AppFileRouter.from_filename(path)
     file_key = file_router.get_unique_file_key()
     assert file_key is not None
     file_manager = file_router.get_file_manager(file_key)
 
-    result = Exporter().export_as_md(file_manager)
+    # py -> md
+    if new_filename:
+        file_manager.filename = str(new_filename)
+    result = Exporter().export_as_md(file_manager, previous=path.path)
     return ExportResult(
         contents=result[0],
         download_filename=result[1],
diff --git a/marimo/_server/export/exporter.py b/marimo/_server/export/exporter.py
index 82abf734eb9..30ba1588e09 100644
--- a/marimo/_server/export/exporter.py
+++ b/marimo/_server/export/exporter.py
@@ -223,45 +223,69 @@ def export_as_ipynb(
         download_filename = get_download_filename(file_manager, "ipynb")
         return stream.read(), download_filename
 
-    def export_as_md(self, file_manager: AppFileManager) -> tuple[str, str]:
-        import yaml
-
+    def export_as_md(
+        self, file_manager: AppFileManager, previous: Path | None = None
+    ) -> tuple[str, str]:
+        from marimo._ast import codegen
         from marimo._ast.app_config import _AppConfig
         from marimo._ast.cell import Cell
         from marimo._ast.compiler import compile_cell
         from marimo._cli.convert.markdown import (
+            extract_frontmatter,
             formatted_code_block,
             is_sanitized_markdown,
         )
+        from marimo._utils import yaml
 
-        # TODO: Provide filter or kernel in yaml header such that markdown
-        # documents are executable.
+        filename = get_filename(file_manager)
+        metadata: dict[str, str | list[str]] = {}
+        metadata.update(
+            {
+                "title": get_app_title(file_manager),
+                "marimo-version": __version__,
+            }
+        )
 
-        #  Put data from AppFileManager into the yaml header.
+        # Put data from AppFileManager into the yaml header.
         ignored_keys = {"app_title"}
-        metadata: dict[str, str | list[str]] = {
-            "title": get_app_title(file_manager),
-            "marimo-version": __version__,
-        }
-
-        def _format_value(v: Optional[str | list[str]]) -> str | list[str]:
-            if isinstance(v, list):
-                return v
-            return str(v)
-
         default_config = _AppConfig().asdict()
 
         # Get values defined in _AppConfig without explicitly extracting keys,
         # as long as it isn't the default.
         metadata.update(
             {
-                k: _format_value(v)
+                k: v
                 for k, v in file_manager.app.config.asdict().items()
                 if k not in ignored_keys and v != default_config.get(k)
             }
         )
+        # If previously a markdown file, extract frontmatter.
+        # otherwise if it was a python file, extract header.
+        if previous and previous.suffix == ".py":
+            header = codegen.get_header_comments(previous)
+            if header:
+                metadata["header"] = header.strip()
+        elif previous:
+            with open(previous, encoding="utf-8") as f:
+                _metadata, _ = extract_frontmatter(f.read())
+            metadata.update(_metadata)
+
+        # Add the expected qmd filter to the metadata.
+        if filename.endswith(".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 = yaml.dump(
+        header = yaml.marimo_compat_dump(
             {
                 k: v
                 for k, v in metadata.items()
diff --git a/marimo/_server/file_manager.py b/marimo/_server/file_manager.py
index c1136c8f1f9..eb229420209 100644
--- a/marimo/_server/file_manager.py
+++ b/marimo/_server/file_manager.py
@@ -129,19 +129,38 @@ def _save_file(
         app_config: _AppConfig,
         # Whether or not to persist the app to the file system
         persist: bool,
+        # Whether save was triggered by a rename
+        previous_filename: Optional[str] = None,
     ) -> str:
         LOGGER.debug("Saving app to %s", filename)
-        if filename.endswith(".md"):
-            # TODO: Remember just proof of concept, potentially needs
-            # restructuring.
+
+        type_changed = (
+            previous_filename and filename[-2:] != previous_filename[-2:]
+        )
+        if filename.endswith(".md") or filename.endswith(".qmd"):
+            # TODO: Potentially restructure, such that code compilation doesn't
+            # have to occur multiple times.
             from marimo._server.export.exporter import Exporter
 
-            contents, _ = Exporter().export_as_md(self)
+            previous = None
+            if previous_filename:
+                previous = Path(previous_filename)
+            contents, _ = Exporter().export_as_md(self, previous)
         else:
             # Header might be better kept on the AppConfig side, opposed to
             # reparsing it. Also would allow for md equivalent in a field like
             # `description`.
-            header_comments = codegen.get_header_comments(filename)
+            if type_changed:
+                from marimo._cli.sandbox import get_headers_from_markdown
+
+                with open(filename, encoding="utf-8") as f:
+                    markdown = f.read()
+                headers = get_headers_from_markdown(markdown)
+                header_comments = headers.get("header", None) or headers.get(
+                    "sandbox", None
+                )
+            else:
+                header_comments = codegen.get_header_comments(filename)
             # try to save the app under the name `filename`
             contents = codegen.generate_filecontents(
                 codes,
@@ -190,19 +209,20 @@ def rename(self, new_filename: str) -> None:
 
         self._assert_path_does_not_exist(new_filename)
 
-        need_save = False
+        needs_save = False
         # Check if filename is not None to satisfy mypy's type checking.
         # This ensures that filename is treated as a non-optional str,
         # preventing potential type errors in subsequent code.
         if self.is_notebook_named and self.filename is not None:
             # Force a save after rename in case filetype changed.
-            need_save = self.filename[-3:] != new_filename[-3:]
+            needs_save = self.filename[-3:] != new_filename[-3:]
             self._rename_file(new_filename)
         else:
             self._create_file(new_filename)
 
+        previous_filename = self.filename
         self.filename = new_filename
-        if need_save:
+        if needs_save:
             self._save_file(
                 self.filename,
                 list(self.app.cell_manager.codes()),
@@ -210,6 +230,7 @@ def rename(self, new_filename: str) -> None:
                 list(self.app.cell_manager.configs()),
                 self.app.config,
                 persist=True,
+                previous_filename=previous_filename,
             )
 
     def read_layout_config(self) -> Optional[LayoutConfig]:
diff --git a/marimo/_tutorials/markdown_format.md b/marimo/_tutorials/markdown_format.md
index 48491606356..41739704df6 100644
--- a/marimo/_tutorials/markdown_format.md
+++ b/marimo/_tutorials/markdown_format.md
@@ -1,6 +1,19 @@
 ---
 title: Markdown
-marimo-version: 0.10.19
+marimo-version: 0.13.2
+author: Marimo Team
+description: >-
+  Markdown is a lightweight markup language with plain text formatting syntax. `marimo`
+  notebooks can be stored as markdown files, allowing you to work on prose-heavy notebooks
+  in your editor of choice.
+sandbox: |-
+  requires-python = ">=3.12"
+  dependencies = [
+      "marimo",
+      "duckdb==1.2.2",
+      "matplotlib==3.10.1",
+      "sqlglot==26.16.2",
+  ]
 ---
 
 # Markdown file format
@@ -262,4 +275,4 @@ more information on to type-set and render markdown in marimo.
 
 ```python {.marimo hide_code="true"}
 import marimo as mo
-```
+```
\ No newline at end of file
diff --git a/marimo/_utils/yaml.py b/marimo/_utils/yaml.py
new file mode 100644
index 00000000000..3fb9ab6d0bf
--- /dev/null
+++ b/marimo/_utils/yaml.py
@@ -0,0 +1,85 @@
+# Copyright 2025 Marimo. All rights reserved.
+from __future__ import annotations
+
+import re
+import sys
+from typing import Any, Callable
+
+import yaml
+from yaml.representer import SafeRepresenter, ScalarNode
+
+if sys.version_info < (3, 10):
+    from typing_extensions import TypeAlias
+else:
+    from typing import TypeAlias
+
+
+# Regex captures loose yaml for frontmatter
+# Should match the following:
+# ---
+# title: "Title"
+# whatever
+# ---
+YAML_FRONT_MATTER_REGEX = re.compile(
+    r"^---\s*\n(.*?\n?)(?:---)\s*\n", re.UNICODE | re.DOTALL
+)
+
+Repr: TypeAlias = Callable[[SafeRepresenter, str], ScalarNode]
+
+
+# represent_str does handle some corner cases, so use that
+# instead of calling represent_scalar directly
+class folded_str(str):
+    pass
+
+
+class literal_str(str):
+    pass
+
+
+def _format_header_value(k: str, v: Any) -> Any:
+    if not isinstance(v, str):
+        return v
+    if k in ("sandbox", "header") or "\n" in v:
+        return literal_str(v)
+    if k == "description":
+        return folded_str(v)
+    return v
+
+
+def dump(data: dict[str, Any], **kwargs: Any) -> str:
+    def _change_style(style: str, representer: Repr) -> Repr:
+        def new_representer(dumper: SafeRepresenter, data: str) -> ScalarNode:
+            scalar = representer(dumper, data)
+            scalar.style = style
+            return scalar
+
+        return new_representer
+
+    represent_folded_str = _change_style(">", SafeRepresenter.represent_str)
+    represent_literal_str = _change_style("|", SafeRepresenter.represent_str)
+    yaml.add_representer(folded_str, represent_folded_str)
+    yaml.add_representer(literal_str, represent_literal_str)
+
+    response = yaml.dump(data, **kwargs)
+    assert isinstance(response, str), f"Expected str, got {type(response)}"
+    return response
+
+
+def marimo_compat_dump(data: dict[str, Any], **kwargs: Any) -> str:
+    safe_data = {k: _format_header_value(k, v) for k, v in data.items()}
+    return dump(safe_data, **kwargs)
+
+
+def load(yaml_content: str) -> dict[str, Any]:
+    # CSafeLoader is faster than SafeLoader.
+    try:
+        from yaml import CSafeLoader as SafeLoader
+    except ImportError:
+        from yaml import SafeLoader  # type: ignore[assignment]
+    response = yaml.load(yaml_content, SafeLoader)
+    assert isinstance(response, dict), f"Expected dict, got {type(response)}"
+    return response
+
+
+YAMLError = yaml.YAMLError
diff --git a/tests/_cli/snapshots/dataflow.md.txt b/tests/_cli/snapshots/dataflow.md.txt
index 02ec27b1618..d6c2027ef0e 100644
--- a/tests/_cli/snapshots/dataflow.md.txt
+++ b/tests/_cli/snapshots/dataflow.md.txt
@@ -1,4 +1,15 @@
 ---
+header: |+
+  # Copyright 2025 Marimo. All rights reserved.
+  # /// script
+  # requires-python = ">=3.12"
+  # dependencies = [
+  #     "marimo",
+  #     "matplotlib==3.10.1",
+  #     "numpy==2.2.4",
+  # ]
+  # ///
+
 title: Dataflow
 marimo-version: 0.0.0
 ---
diff --git a/tests/_cli/snapshots/frontmatter-test.py.txt b/tests/_cli/snapshots/frontmatter-test.py.txt
index 8f9d6c934ba..201e5bc2a10 100644
--- a/tests/_cli/snapshots/frontmatter-test.py.txt
+++ b/tests/_cli/snapshots/frontmatter-test.py.txt
@@ -1,3 +1,6 @@
+#!/usr/bin/env python
+# and some other random stuff
+
 import marimo
 
 __generated_with = "0.0.0"
diff --git a/tests/_cli/snapshots/marimo_for_jupyter_users.md.txt b/tests/_cli/snapshots/marimo_for_jupyter_users.md.txt
index 267908f569c..5e0e2204c30 100644
--- a/tests/_cli/snapshots/marimo_for_jupyter_users.md.txt
+++ b/tests/_cli/snapshots/marimo_for_jupyter_users.md.txt
@@ -1,4 +1,7 @@
 ---
+header: |+
+  # Copyright 2024 Marimo. All rights reserved.
+
 title: marimo for Jupyter users
 marimo-version: 0.0.0
 ---
diff --git a/tests/_cli/snapshots/sql.md.txt b/tests/_cli/snapshots/sql.md.txt
index 142a5cce77c..79f25a90c39 100644
--- a/tests/_cli/snapshots/sql.md.txt
+++ b/tests/_cli/snapshots/sql.md.txt
@@ -1,4 +1,18 @@
 ---
+header: |+
+  # Copyright 2025 Marimo. All rights reserved.
+  # /// script
+  # requires-python = ">=3.12"
+  # dependencies = [
+  #     "duckdb==1.2.2",
+  #     "marimo",
+  #     "pandas==2.2.3",
+  #     "polars==1.27.1",
+  #     "pyarrow==19.0.1",
+  #     "sqlglot==26.13.0",
+  # ]
+  # ///
+
 title: Sql
 marimo-version: 0.0.0
 width: medium
diff --git a/tests/_cli/test_markdown_conversion.py b/tests/_cli/test_markdown_conversion.py
index 8ccb31757b4..0d3cf2163f7 100644
--- a/tests/_cli/test_markdown_conversion.py
+++ b/tests/_cli/test_markdown_conversion.py
@@ -95,6 +95,9 @@ def test_markdown_frontmatter() -> None:
     filters:
     - name: "filter1"
     - name: "filter2"
+    header: |
+        #!/usr/bin/env python
+        # and some other random stuff
     ---
 
     # Notebook
@@ -109,6 +112,7 @@ def test_markdown_frontmatter() -> None:
     # As python file
     output = sanitized_version(convert_from_md(script))
     assert 'app_title="My Title"' in output
+    assert output.startswith("#!/usr/bin/env python")
     snapshot("frontmatter-test.py.txt", output)
 
     # As python object
@@ -239,6 +243,22 @@ def test_markdown_empty() -> None:
     assert app.cell_manager.cell_data_at(ids[0]).code == ""
 
 
+def test_python_to_md_header() -> None:
+    script = dedent(
+        remove_empty_lines(
+            """
+        #!/usr/bin/env python
+        import marimo
+        __generated_with = "0.0.0"
+        app = marimo.App()
+        """
+        )
+    )
+    md = convert_from_py(script)
+    assert "#!/usr/bin/env python" in md
+    snapshot("has-header.md.txt", md)
+
+
 def test_python_to_md_code_injection() -> None:
     unsafe_app = dedent(
         remove_empty_lines(
diff --git a/tests/_cli/test_sandbox.py b/tests/_cli/test_sandbox.py
index a4719c3a24d..05319f19a12 100644
--- a/tests/_cli/test_sandbox.py
+++ b/tests/_cli/test_sandbox.py
@@ -564,3 +564,109 @@ def test_construct_uv_cmd_with_additional_deps() -> None:
         requirements = f.read()
         assert "numpy>=1.20.0" in requirements
         assert "pandas" in requirements
+
+
+def test_markdown_sandbox(tmp_path: Path) -> None:
+    # Test Python version requirement is passed through
+    script_path = tmp_path / "test.md"
+    script_path.write_text(
+        """---
+title: Test
+sandbox: |
+    requires-python = ">=3.11"
+    dependencies = ["numpy"]
+---
+
+Hello world!"""
+    )
+    uv_cmd = construct_uv_command(
+        ["edit", str(script_path), "--sandbox"],
+        str(script_path),
+        additional_features=[],
+        additional_deps=[],
+    )
+    assert "--python" in uv_cmd
+    assert ">=3.11" in uv_cmd
+    assert "--isolated" in uv_cmd
+    assert "--no-project" in uv_cmd
+    assert "--compile-bytecode" in uv_cmd
+    assert "--sandbox" not in uv_cmd
+
+    req_file_index = uv_cmd.index("--with-requirements") + 1
+    req_file_path = uv_cmd[req_file_index]
+    with open(req_file_path) as f:
+        requirements = f.read()
+        assert "numpy" in requirements
+
+
+def test_markdown_header(tmp_path: Path) -> None:
+    # Test Python version requirement is passed through
+    script_path = tmp_path / "test.md"
+    script_path.write_text(
+        """---
+title: Test
+sandbox: |
+header: |
+    #! /usr/bin/env python
+    # /// script
+    # requires-python = ">=3.11"
+    # dependencies = ["numpy"]
+    # ///
+    "Other metadata"
+---
+import marimo
+    """
+    )
+    uv_cmd = construct_uv_command(
+        ["edit", str(script_path), "--sandbox"],
+        str(script_path),
+        additional_features=[],
+        additional_deps=[],
+    )
+    assert "--python" in uv_cmd
+    assert ">=3.11" in uv_cmd
+    assert "--isolated" in uv_cmd
+    assert "--no-project" in uv_cmd
+    assert "--compile-bytecode" in uv_cmd
+    assert "--sandbox" not in uv_cmd
+
+    req_file_index = uv_cmd.index("--with-requirements") + 1
+    req_file_path = uv_cmd[req_file_index]
+    with open(req_file_path) as f:
+        requirements = f.read()
+        assert "numpy" in requirements
+
+
+def test_markdown_sandbox_and_header(tmp_path: Path) -> None:
+    # Test Python version requirement is passed through
+    script_path = tmp_path / "test.md"
+    script_path.write_text(
+        """---
+title: Test
+sandbox: |
+    requires-python = ">=3.11"
+    dependencies = ["numpy"]
+header: |
+    #! /usr/bin/env python
+---
+import marimo
+    """
+    )
+    uv_cmd = construct_uv_command(
+        ["edit", str(script_path), "--sandbox"],
+        str(script_path),
+        additional_features=[],
+        additional_deps=[],
+    )
+    assert "--python" in uv_cmd
+    assert ">=3.11" in uv_cmd
+    assert "--isolated" in uv_cmd
+    assert "--no-project" in uv_cmd
+    assert "--compile-bytecode" in uv_cmd
+    assert "--sandbox" not in uv_cmd
+
+    req_file_index = uv_cmd.index("--with-requirements") + 1
+    req_file_path = uv_cmd[req_file_index]
+    with open(req_file_path) as f:
+        requirements = f.read()
+        assert "numpy" in requirements
diff --git a/tests/_server/test_file_manager.py b/tests/_server/test_file_manager.py
index ad631617184..076a3df2636 100644
--- a/tests/_server/test_file_manager.py
+++ b/tests/_server/test_file_manager.py
@@ -148,6 +148,27 @@ def test_rename_different_filetype(app_file_manager: AppFileManager) -> None:
         assert "app = marimo.App()" not in contents
 
 
+def test_rename_to_qmd(app_file_manager: AppFileManager) -> None:
+    initial_filename = app_file_manager.filename
+    assert initial_filename
+    assert initial_filename.endswith(".py")
+    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(initial_filename[:-3] + ".qmd")
+    next_filename = app_file_manager.filename
+    assert next_filename
+    assert next_filename.endswith(".qmd")
+    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
+
+
 def test_save_app_config_valid(app_file_manager: AppFileManager) -> None:
     app_file_manager.filename = "app_config.py"
     try:


Code review comments:
diff_hunk: @@ -232,11 +222,16 @@ def _tree_to_app(root: Element) -> str:
         )
         sources.append(get_source_from_tag(child))
 
+    header = root.get("header", None)
+    sandbox = root.get("sandbox", None)
path: marimo/_cli/convert/markdown.py
text: @user1:
`pyproject` or `script_metadata` might be a better name, since you can include config in the sandbox as well (that gets used outside the sandbox)

@author:
Agreed