Below you will find a piece of python code which copies items from your published obsidian vault folder, over to your quartz content folder.

I made this because I installed quartz empty and want to semi-manually add my files if I want to publish them to quartz, but in an automated way.

This script can be used for any file copy intentions, however, my use case is the Obsidian and Quartz 4 integration.

The full script is at the bottom of the page

Please note

This script is intended to use with the empty folder Quartz install, not with copy or symlink.

Functional design of the code

Step 1: Deleting the source contents of the Quartzcontent folder.

Before anything can be copied from your Obsidian vault, the script first wipes the Quartz content folder clean. This ensures Quartz receives a perfect one-to-one mirror of your Published folder, without leftover files from previous runs.

This is intentional. Quartz does not introspect or clean itself — if old files remain, they may continue to appear on your published site even after you remove them from Obsidian. Clearing the folder guarantees a predictable, consistent state.

Here’s the function:

def clean_dest_contents():
    """
    Ensure DEST_DIR exists, then remove everything *inside* it
    without deleting the DEST_DIR folder itself.
    """
    if not os.path.exists(DEST_DIR):
        print(f"[INFO] Destination does not exist, creating: {DEST_DIR}")
        os.makedirs(DEST_DIR, exist_ok=True)
        return
 
    print(f"[INFO] Clearing existing contents of: {DEST_DIR}")
    for entry in os.listdir(DEST_DIR):
        path = os.path.join(DEST_DIR, entry)
        if os.path.isdir(path) and not os.path.islink(path):
            shutil.rmtree(path)
        else:
            os.remove(path)
 

What this step does

  • Checks if the Quartz content folder exists
    If it doesn’t, the script creates it. This makes the script safe to run on a freshly cloned Quartz repository.

  • Deletes all files and folders inside content
    Important: the folder itself is not deleted — only its contents.
    Quartz expects this folder to exist.

  • Removes everything, regardless of type
    Regular files, images, Markdown notes, subfolders, and old assets all get removed.
    Symlinks get removed as files.

Why this step matters

  • It prevents “ghost notes” from older versions of your site.

  • It ensures your website always reflects only the notes you intended to publish.

  • It removes the need to manually clean your Quartz repo when reorganizing notes.

  • It keeps the sync behaviour simple and transparent:
    Obsidian Published → Quartz content with no questions asked.

When this step is dangerous

If you store anything manually inside your Quartz content folder that is not generated by this script, you will lose it.
The folder is meant to be treated as disposable and fully regenerated on each run.

If you need special files (custom images, scripts, or assets), either:

  • store them in Obsidian so they get copied automatically, or

  • place them outside the content folder (for example in Quartz’s static/ folder)

Step 2: Copying files from your Obsidian folder into Quartz

After the destination content folder has been cleared, the script walks through your Obsidian Published folder and copies everything into Quartz:

def copy_tree(src_root, dst_root):
    for root, dirs, files in os.walk(src_root):
        # Filter ignored dirs in-place so os.walk won't descend into them
        dirs[:] = [d for d in dirs if d not in IGNORED_DIRS]
 
        # Compute relative path from src_root and corresponding dst dir
        rel_path = os.path.relpath(root, src_root)
        dst_dir = os.path.join(dst_root, rel_path) if rel_path != "." else dst_root
 
        os.makedirs(dst_dir, exist_ok=True)
 
        for name in files:
            if name in IGNORED_FILES:
                continue
 
            src_file = os.path.join(root, name)
            dst_file = os.path.join(dst_dir, name)
 
            shutil.copy2(src_file, dst_file)
            print(f"[COPY] {src_file} -> {dst_file}")
 

A few important details here:

  • os.walk(src_root) iterates over all subfolders in your Obsidian Published folder.

  • dirs[:] = [...] removes ignored directories in-place, which means os.walk will not even descend into those folders.

  • rel_path keeps your folder structure intact. If you have Published/coffee/recipes, you’ll get content/coffee/recipes.

  • shutil.copy2 is used instead of shutil.copy so file metadata (timestamps, etc.) is preserved as much as possible.

This is the part that gives you the “semi-manual but automated” behaviour: you decide what lives in your Obsidian Published folder, and the script mirrors that into Quartz exactly.

Step 3: Ignore lists (keeping junk and internal stuff out)

The script has two ignore lists at the top:

# Directories to ignore by name
IGNORED_DIRS = {
    ".obsidian",
    ".trash",
    ".git",
    ".github",
    "node_modules",
    "__pycache__",
    ".SynologyWorkingDirectory",
}
 
# Files to ignore by name
IGNORED_FILES = {
    "Thumbs.db",
    "desktop.ini",
}
 

These are there to prevent:

  • Obsidian’s own workspace/config files from ending up in your Quartz site.

  • Synology and Windows junk (like Thumbs.db) from polluting your repo.

  • Development folders like node_modules or __pycache__ from being copied.

You can freely add or remove entries depending on what lives in your vault. For example, if you have a local “scratch” or “archive” folder you never want to publish, just add its folder name to IGNORED_DIRS.

Step 4: Configuration – what you must change

At the top of the script you’ll find:

# Obsidian "Published" folder (source)
SOURCE_DIR = r"C:\path/to/your/obsidian/vault/published/folder"
 
# Quartz "content" folder (destination)
DEST_DIR = r"C:\Users\<youruserhere>\github\quartz\content"
 

You need to:

  1. Point SOURCE_DIR to the folder in your Obsidian vault that contains only the notes you want to publish (for example a Published or Public folder).

  2. Point DEST_DIR to your Quartz 4 content folder.

Important: DEST_DIR itself is not deleted, but everything inside it is cleared on each run. So make sure that folder is dedicated to Quartz content you’re happy to regenerate.

Step 5: How to run the script

  1. Save the script to a file, for example:

    C:\Users\<youruser>\scripts\sync_obsidian_to_quartz.py

  2. Make sure Python is installed and available in your PATH.

  3. Open a terminal (PowerShell or Command Prompt) and run:

    python C:\Users\<youruser>\scripts\sync_obsidian_to_quartz.py
  1. You’ll see log lines like:
    === Sync Obsidian -> Quartz content ===
Source:      C:\...\Published
Destination: C:\...\quartz\content
 
[INFO] Clearing existing contents of: C:\...\quartz\content
[COPY] C:\...\Published\index.md -> C:\...\quartz\content\index.md
[COPY] C:\...\Published\coffee\recipe.md -> C:\...\quartz\content\coffee\recipe.md
 
[DONE] Quartz content is now in sync with Obsidian.
 

From there, you can run your normal Quartz build/deploy command (for example via npx quartz build or your GitHub Actions workflow).

Safety considerations

Be very clear about one thing:

The script deletes everything inside DEST_DIR before copying.

That’s intentional, because Quartz should mirror your Obsidian Published folder exactly. But it also means:

  • Don’t point DEST_DIR at any folder that contains files you manually manage and don’t want to lose.

  • Keep DEST_DIR exclusively for Quartz content that’s generated or synced.

  • If you want a safer first run, you can temporarily comment out the clean_dest_contents() call and just inspect what would be copied.

If you want to make it extra safe, you could create a Git commit in your Quartz repo before running the script. That way, if something goes wrong, you can just revert in Git.

Optional extensions (ideas)

If you want to push this further, some possible upgrades:

  • Dry-run mode: add a flag that prints what would be deleted/copied without actually changing files.

  • Selective sync: instead of clearing the whole content folder, only overwrite files that changed or only sync a subfolder (e.g. Published/coffee → content/coffee).

  • Logging to file: write all [COPY] lines to a log file so you can see what changed during each run.

  • Pre-/post-hooks: run git status, git add or npx quartz build automatically after syncing.

For my use case, this “dumb but predictable” behaviour is exactly what I want: I curate which notes go into my Obsidian Published folder, and the script takes care of mirroring that state into Quartz in one command.

The full script:

#!/usr/bin/env python3
"""
Sync Obsidian Published folder -> Quartz content folder.
 
- Clears all files/subfolders inside the Quartz content folder
- Copies everything from source to destination
- Skips some known junk folders/files
"""
 
import os
import shutil
 
# === CONFIG – ADJUST THESE ===
 
# Obsidian "Published" folder (source)
SOURCE_DIR = r"C:\path/to/your/obsidian/vault/published/folder"
 
# Quartz "content" folder (destination)
DEST_DIR = r"C:\Users\<youruserhere>\github\quartz\content"
 
# Directories to ignore by name
IGNORED_DIRS = {
    ".obsidian",
	".trash",
    ".git",
    ".github",
    "node_modules",
    "__pycache__",
	".SynologyWorkingDirectory",
}
 
# Files to ignore by name
IGNORED_FILES = {
    "Thumbs.db",
    "desktop.ini",
}
 
 
# === IMPLEMENTATION – NO NEED TO TOUCH BELOW THIS LINE ===
 
def ensure_source_exists():
    if not os.path.isdir(SOURCE_DIR):
        raise SystemExit(f"[ERROR] Source directory does not exist:\n  {SOURCE_DIR}")
 
 
def clean_dest_contents():
    """
    Ensure DEST_DIR exists, then remove everything *inside* it
    without deleting the DEST_DIR folder itself.
    """
    if not os.path.exists(DEST_DIR):
        print(f"[INFO] Destination does not exist, creating: {DEST_DIR}")
        os.makedirs(DEST_DIR, exist_ok=True)
        return
 
    print(f"[INFO] Clearing existing contents of: {DEST_DIR}")
    for entry in os.listdir(DEST_DIR):
        path = os.path.join(DEST_DIR, entry)
        if os.path.isdir(path) and not os.path.islink(path):
            shutil.rmtree(path)
        else:
            os.remove(path)
 
 
def copy_tree(src_root, dst_root):
    for root, dirs, files in os.walk(src_root):
        # Filter ignored dirs in-place so os.walk won't descend into them
        dirs[:] = [d for d in dirs if d not in IGNORED_DIRS]
 
        # Compute relative path from src_root and corresponding dst dir
        rel_path = os.path.relpath(root, src_root)
        dst_dir = os.path.join(dst_root, rel_path) if rel_path != "." else dst_root
 
        os.makedirs(dst_dir, exist_ok=True)
 
        for name in files:
            if name in IGNORED_FILES:
                continue
 
            src_file = os.path.join(root, name)
            dst_file = os.path.join(dst_dir, name)
 
            shutil.copy2(src_file, dst_file)
            print(f"[COPY] {src_file} -> {dst_file}")
 
 
def main():
    print("=== Sync Obsidian -> Quartz content ===")
    print(f"Source:      {SOURCE_DIR}")
    print(f"Destination: {DEST_DIR}")
    print()
 
    ensure_source_exists()
    clean_dest_contents()
    copy_tree(SOURCE_DIR, DEST_DIR)
 
    print()
    print("[DONE] Quartz content is now in sync with Obsidian.")
 
 
if __name__ == "__main__":
    main()