Syncing Obsidian with Quarto

Obsidian
Quarto
Python
Author

Meghana Bhange

Published

December 13, 2025

🌱 Building a Digital Garden with Obsidian, Quarto, and GitHub

I keep a lot of notes. Ideas, half-written thoughts, small experiments, things I want to come back to later. For a long time, these lived only in my local Obsidian vault — private, messy, and useful only to me.

At the same time, I wanted a public-facing space that:

  • didn’t force every post to be “finished”
  • allowed ideas to grow over time
  • felt closer to a notebook than a blog

That’s how I ended up building a digital garden using:

  • Obsidian for writing
  • Quarto for publishing
  • GitHub Pages for hosting
  • a small Python script to glue everything together

This post walks through that setup step by step.


✍️ Writing in Obsidian

I use Obsidian as my primary writing tool because:

  • Markdown is simple and portable
  • Notes are just files on disk
  • Linking ideas is effortless

My folder structure looks roughly like this:

Digital Garden/
├── Digital Garden.md
├── Obsidian Tips and Tricks/
│   └── Syncing Obsidian with Quarto.md
├── Quarto/
│   └── Publishing with GitHub Pages.md

A few conventions I follow:

  • Folder names = categories
  • File name = page title

📦 What is Quarto?

Quarto is a publishing system built on Pandoc. It lets you write Markdown and publish it as blogs, documentation, academic papers, or personal websites.

Why I chose Quarto:

  • Markdown-first
  • Native support for drafts
  • Excellent GitHub Pages integration
  • Clean defaults, minimal configuration

🛠 Setting Up a Quarto Website

quarto create-project my-garden --type website
cd my-garden

Project structure:

my-garden/
├── _quarto.yml
├── index.qmd
├── posts/
└── styles.css

🚀 Publishing with GitHub Pages

quarto publish gh-pages

Quarto builds the site and deploys it to GitHub Pages automatically.


🔄 Syncing Obsidian → Quarto

To avoid manual copying, I use a small Python script to sync notes from Obsidian into Quarto posts.

Rules:

  1. Every .md file becomes a Quarto post
  2. Folder names become categories
  3. File name becomes the title
  4. Everything is published as draft: true
  5. Changes are detected via a content hash

🧩 The Sync Script

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple, Union

# We embed metadata at the bottom of generated files so we can detect changes.
SYNC_MARKER_PREFIX = "<!-- obsidian-sync:"
SYNC_MARKER_RE = re.compile(r"<!--\s*obsidian-sync:\s*(\{.*?\})\s*-->")

# Very small frontmatter detector
FM_START = "---"
FM_END = "---"


def sha256_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def slugify(s: str) -> str:
    s = s.strip().lower()
    s = s.replace("&", " and ")
    s = s.replace("’", "'").replace("'", "")
    s = re.sub(r"[^a-z0-9]+", "-", s)
    s = re.sub(r"-{2,}", "-", s).strip("-")
    return s or "post"


def escape_yaml_double_quotes(s: str) -> str:
    return s.replace("\\", "\\\\").replace('"', '\\"')


def yaml_list(items: List[str]) -> str:
    quoted = [f'"{escape_yaml_double_quotes(x)}"' for x in items]
    return "[" + ", ".join(quoted) + "]"


def guess_date_from_mtime(path: Path) -> str:
    dt = datetime.fromtimestamp(path.stat().st_mtime)
    return dt.strftime("%Y-%m-%d")


def read_existing_sync_meta(qmd_text: str) -> Optional[dict]:
    """
    Reads our sync marker like:
      <!-- obsidian-sync:{"source":"...","hash":"..."} -->
    Returns dict or None.
    """
    m = SYNC_MARKER_RE.search(qmd_text)
    if not m:
        return None

    raw = m.group(1)
    meta = {}
    for k, v in re.findall(r'"([^"]+)"\s*:\s*"([^"]*)"', raw):
        meta[k] = v
    return meta or None


def is_hidden_or_obsidian_internal(path: Path) -> bool:
    # Skip hidden dirs/files and .obsidian internals
    for part in path.parts:
        if part.startswith("."):
            return True
    return False


def iter_md_files(root: Path) -> Iterable[Path]:
    for p in root.rglob("*.md"):
        if is_hidden_or_obsidian_internal(p):
            continue
        yield p


def split_frontmatter(text: str) -> Tuple[Optional[str], str]:
    """
    Returns (frontmatter_text_or_None, body_text).
    Assumes frontmatter is at the very top of the file and delimited by --- lines.
    """
    if not text.startswith(FM_START + "\n"):
        return None, text

    # Find the next line that is exactly '---'
    end_idx = text.find("\n" + FM_END + "\n", len(FM_START) + 1)
    if end_idx == -1:
        # Malformed frontmatter; treat as no frontmatter
        return None, text

    fm = text[len(FM_START) + 1 : end_idx]  # between --- and --- (no surrounding newlines)
    body = text[end_idx + len("\n" + FM_END + "\n") :]
    return fm, body


def parse_scalar(value: str) -> Union[str, bool]:
    v = value.strip()
    if v.lower() == "true":
        return True
    if v.lower() == "false":
        return False
    return v


def parse_frontmatter_minimal(fm_text: str) -> Dict[str, object]:
    """
    Minimal YAML-ish parser supporting:
      key: value
      key:
        - item
        - item
    for your use case (title/date/draft/digital_garden/Additional Tags).

    Not a full YAML parser; intentionally small & predictable.
    """
    data: Dict[str, object] = {}
    lines = fm_text.splitlines()

    i = 0
    while i < len(lines):
        line = lines[i].rstrip()
        i += 1

        if not line.strip():
            continue

        # key: value  OR  key:
        m = re.match(r"^([A-Za-z0-9_ -]+):\s*(.*)$", line)
        if not m:
            continue

        key = m.group(1).strip()
        rest = m.group(2).strip()

        if rest != "":
            # scalar
            data[key] = parse_scalar(rest)
            continue

        # key:  (maybe a list below)
        items: List[str] = []
        while i < len(lines):
            nxt = lines[i].rstrip()
            # list items are indented + start with "-"
            if re.match(r"^\s+-\s+.+$", nxt):
                item = re.sub(r"^\s+-\s+", "", nxt).strip()
                items.append(item)
                i += 1
                continue
            # stop when indentation/list ends
            break

        data[key] = items

    return data


@dataclass
class Note:
    source_path: Path
    title: str
    slug: str
    date: str
    draft: bool
    categories: List[str]
    digital_garden: bool
    body_md: str
    source_hash: str


def build_note(source_path: Path) -> Note:
    raw = source_path.read_text(encoding="utf-8")
    fm_text, body = split_frontmatter(raw)

    fm: Dict[str, object] = parse_frontmatter_minimal(fm_text) if fm_text else {}

    title = str(fm.get("title") or source_path.stem)
    slug = slugify(title)

    # date: prefer FM, else mtime
    date_val = fm.get("date")
    if isinstance(date_val, str) and date_val.strip():
        post_date = date_val.strip()
    else:
        post_date = guess_date_from_mtime(source_path)

    # draft: default true (matches your workflow)
    draft_val = fm.get("draft")
    draft = bool(draft_val) if isinstance(draft_val, bool) else True

    # digital_garden: default false (only sync true)
    dg_val = fm.get("digital_garden")
    digital_garden = bool(dg_val) if isinstance(dg_val, bool) else False

    # Additional Tags -> categories
    tags = fm.get("Additional Tags")
    categories = [str(x) for x in tags] if isinstance(tags, list) else []

    # Hash includes path + full raw content (frontmatter + body)
    content_for_hash = f"{source_path.as_posix()}\n---\n{raw}"
    src_hash = sha256_text(content_for_hash)

    # Keep the body as-is (including headings). We already removed frontmatter by splitting.
    body_md = body.lstrip("\n")

    return Note(
        source_path=source_path,
        title=title,
        slug=slug,
        date=post_date,
        draft=draft,
        categories=categories,
        digital_garden=digital_garden,
        body_md=body_md,
        source_hash=src_hash,
    )


def render_qmd(note: Note, author: Optional[str]) -> str:
    sync_meta = (
        f'{SYNC_MARKER_PREFIX}{{"source":"{note.source_path.as_posix()}",'
        f'"hash":"{note.source_hash}"}} -->'
    )

    yaml_lines: List[str] = [
        "---",
        f'title: "{escape_yaml_double_quotes(note.title)}"',
    ]

    if author:
        yaml_lines.append(f'author: "{escape_yaml_double_quotes(author)}"')

    yaml_lines += [
        f'date: "{note.date}"',
        f"categories: {yaml_list(note.categories)}",
        f"draft: {'true' if note.draft else 'false'}",
        "---",
        "",
    ]

    return "\n".join(yaml_lines) + note.body_md.rstrip() + "\n\n" + sync_meta + "\n"


def should_update(dest_path: Path, new_hash: str) -> bool:
    if not dest_path.exists():
        return True
    existing = dest_path.read_text(encoding="utf-8")
    meta = read_existing_sync_meta(existing)
    if not meta:
        return True
    return meta.get("hash") != new_hash


def main() -> int:
    ap = argparse.ArgumentParser(
        description="One-way sync: Obsidian markdown notes (digital_garden: true) -> Quarto posts/<slug>/index.qmd"
    )
    ap.add_argument("--obsidian-root", required=True, help="Path to your Obsidian vault (or subfolder)")
    ap.add_argument("--quarto-root", required=True, help="Path to your Quarto project root (contains posts/)")
    ap.add_argument("--posts-dir", default="Digital Garden", help="Posts directory under Quarto root (default: posts)")
    ap.add_argument("--author", default=None, help="Author name to include in YAML (optional)")
    ap.add_argument("--dry-run", action="store_true", help="Show changes without writing files")
    args = ap.parse_args()

    obsidian_root = Path(args.obsidian_root).expanduser().resolve()
    quarto_root = Path(args.quarto_root).expanduser().resolve()
    posts_root = (quarto_root / args.posts_dir).resolve()

    if not obsidian_root.exists():
        raise SystemExit(f"Obsidian root not found: {obsidian_root}")
    if not quarto_root.exists():
        raise SystemExit(f"Quarto root not found: {quarto_root}")

    posts_root.mkdir(parents=True, exist_ok=True)

    created = updated = skipped = ignored = 0

    for md_path in iter_md_files(obsidian_root):
        note = build_note(md_path)

        # Only sync notes explicitly marked for publishing
        if not note.digital_garden:
            ignored += 1
            continue

        # Preserve Obsidian folder structure under posts/
        rel_dir = note.source_path.parent.relative_to(obsidian_root)  # e.g. "Obsidian tip and tricks/Subfolder"
        out_dir = posts_root / rel_dir 
        out_file = out_dir / f"{note.slug}.qmd"

        qmd_text = render_qmd(note, author=args.author)

        if should_update(out_file, note.source_hash):
            action = "CREATE" if not out_file.exists() else "UPDATE"
            print(f"{action}: {out_file}  <=  {md_path}")
            if not args.dry_run:
                out_dir.mkdir(parents=True, exist_ok=True)
                out_file.write_text(qmd_text, encoding="utf-8")
            created += 1 if action == "CREATE" else 0
            updated += 1 if action == "UPDATE" else 0
        else:
            print(f"SKIP (no change): {out_file}")
            skipped += 1

    print(f"\nDone. Created: {created}, Updated: {updated}, Skipped: {skipped}, Ignored (digital_garden!=true): {ignored}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

How to run this?

python3 sync_obsidian_to_quarto.py \
--obsidian-root "path/to/obsidian_folder" \
--quarto-root "path/to/quarto_project/root" \
--author "Your Name"