karimould.dev

05/30/2026

Using my Obsidian Vault (but not all of it) for my Astro page

I want to use my Obsidian Vault for my Website. But i dont want to maintain two vaults and I dont want to change my note taking workflow because otherwise I wont stick to it.

I wrote about how I do my todos here, maybe I will also write about my note taking workflow.

There are projects like Quartz and the Vault CMS Project that do something similar but they dont match what I need.

If you dont want to read all of this you can jump to The finished files

The idea

I have a folder in my Obsidian vault called “Digital Garden”. Everything in there is a candidate for the website. But not everything gets published — only files where i explicitly set obsidian: true in the frontmatter.

The rest of my vault stays private. No git, no deploy, no nothing. Just my notes.

A sync script copies the marked files into my Astro project, transforms the Obsidian-specific syntax into something Astro understands, and writes them as regular .md files into the content collection. My hand-crafted .mdx posts (for interactive ones with javascript) live in the same folder and never get touched.

My Workflow

The journey of an Obsidian note to a published post looks like this:

  1. I write a note in Obsidian with obsidian: true in the frontmatter
  2. I run npm run sync
  3. The script deletes all previously synced posts (anything with obsidian: true in the output folder) and all copied images — clean slate every time so i dont need to diff or track what changed
  4. It reads all .md files recursively from my garden folder
  5. It transforms the content (images, wikilinks, callouts, heading links)
  6. It writes the result to src/content/posts/<slug>/index.md
  7. Astro builds it like any other content collection post

The “delete everything and re-copy” approach is intentionally simple. I dont need to track state or figure out what changed. Its fast enough for my amount of posts (for now its enough) and i never end up with stale files.

Some problems with Obsidian markdown

Obsidian markdown is not standard markdown. It has its own syntax for things that Astro doesnt understand. The sync script needs to transform all of it.

There are some more things to know about the Obsidian markdown you can read them here

Images

Obsidian uses ![image.png](../../../images/obsidian/using-my-obsidian-vault-but-not-all-if-it-for-my-astro-page-1.png) to embed images. Sometimes with a resize parameter: ![screenshot.png](../../../images/obsidian/using-my-obsidian-vault-but-not-all-if-it-for-my-astro-page-2.png) where 695 is the width in pixels.

Astro has no idea what that means.

On top of that, Obsidian stores all attachments in a separate folder (in my case 99 - Meta/attachments/). So the image file isnt next to the note — its somewhere else in the vault.

The solution: the script finds all ![...](../../../images/obsidian/using-my-obsidian-vault-but-not-all-if-it-for-my-astro-page-3.) image embeds, strips the resize parameter (the |695 part — that one took me a while to figure out why images werent being found), copies the actual file from the attachments folder to src/images/obsidian/ with a new name (my-post-title-1.png, my-post-title-2.png, etc.), and rewrites the embed as a relative markdown image path that Astro’s image optimization picks up automatically.

Obsidian uses Note Name to link between notes. Sometimes with display text: Click here.

The problem is that some of these link to notes inside my digital garden (those should become real links on the website) and some link to private notes outside the garden (those should just show the plain text without a link).

The script builds a list of all filenames in the garden folder and checks each wikilink against it. If the target exists — it becomes a proper markdown link with a slugified URL. If not — the brackets get stripped and you just see the text.

Callouts

Obsidian has a callout syntax that looks like this:

<div class="border-4 p-4 bg-white my-8 border-warning shadow-warning relative">
  <h3 class="text-2xl font-bold mb-4 font-sans text-midnight">Optional Title</h3>
  <div class="prose prose-midnight">

Content inside the callout

  </div>
</div>

In Obsidian this renders as a nice colored box. In standard markdown its just a blockquote with weird text at the start.

The script transforms these into HTML that matches my own InfoBox component — same borders, same shadows, same styles. The callout type (info, warning, note) maps to different color schemes. This solution is not optimal, now i need to maintain two components but for now its okay I want to focus on my writing and publishing, maybe i revisit it later and optimize it so it uses the .astro component I already have.

In Obsidian you can link to a heading in the same note with #Heading Name. Standard markdown doesnt know what to do with a standalone # that isnt at the start of a line.

The script converts these to proper anchor links: [Heading Name](#heading-name).

Coexisting with hand-crafted posts

I plan to have interactive posts written in .mdx that use custom Astro components with javascript. These live in the same src/content/posts/ folder.

The cleanup routine only deletes folders where the index.md file has obsidian: true in its frontmatter. Since hand-crafted posts are will not have the obsidian prop in the frontmatter they wont get deleted.

The Obsidian template

I use the Templater plugin to auto-fill frontmatter when i create a new note in my garden folder:

---
title: "Nice Things 01.06.2026"
date: "2026-06-01"
spoiler: ""
draft: true
type: "null"
lang: "en"
obsidian: true
featured: false
tags:
---

The tp.system.suggester gives me a modal to pick the post type. I added the obsidian flag so I can differ between things i wrote in Obsidian and things i wrote directly in Astro.

Setup

Bercause I want that this works on multiple mashines I added these three env vars in .env:

OBSIDIAN_ROOT_PATH="/path/to/your/vault"
OBSIDAN_FOLDER_TO_PUBLISH="/07 - Digital Garden"
OBSIDAN_ATTACHMENTS_FOLDER="/99 - Meta/attachments"

To make it easy to fire I added this to the package.json:

"sync": "tsx --env-file=.env scripts/sync-obsidian.ts"

Now I can just run npm run sync and thats it. My dev and build commands also run sync first so i never forget.

The finished files

Three files in scripts/:

sync-obsidian.ts

The entry point. Just config and the main:

import path from "path";
import fs from "fs";
import type { SyncConfig } from "./sync-obsidian.types";
import {
  findMarkdownFiles,
  cleanSyncedPosts,
  cleanSyncedImages,
  transformFiles,
  writePosts,
} from "./sync-obsidian.utils";

const config: SyncConfig = {
  gardenPath: path.join(process.env.OBSIDIAN_ROOT_PATH!, process.env.OBSIDAN_FOLDER_TO_PUBLISH!),
  attachmentsPath: path.join(process.env.OBSIDIAN_ROOT_PATH!, process.env.OBSIDAN_ATTACHMENTS_FOLDER!),
  outputDir: path.resolve("src/content/posts"),
  imagesDir: path.resolve("src/images/obsidian"),
};

async function main() {
  console.log("Start Obsidian Sync\n");

  if (!fs.existsSync(config.gardenPath)) {
    console.error(`Garden path does not exist: ${config.gardenPath}`);
    process.exit(1);
  }

  cleanSyncedPosts(config);
  cleanSyncedImages(config);

  const files = findMarkdownFiles(config.gardenPath);
  console.log(`Found ${files.length} markdown files`);

  const posts = transformFiles(files, config);
  writePosts(posts, config);

  console.log(`\nDone. Synced: ${posts.length}, Skipped: ${files.length - posts.length}`);
}

main().catch((err) => {
  console.error("Sync failed:", err);
  process.exit(1);
});

sync-obsidian.types.ts

export interface SyncConfig {
  gardenPath: string;
  attachmentsPath: string;
  outputDir: string;
  imagesDir: string;
}

export type postType = "post" | "short" | "sidequest" | "interactive";

export interface AstroFrontmatter {
  title: string;
  date: string;
  draft: boolean;
  lang: "en" | "de";
  type: postType;
  spoiler: string;
  obsidian: boolean;
  heroImage?: string;
  featured?: boolean;
  translationKey?: string;
  tags?: string[];
}

export interface ParsedNote {
  slug: string;
  frontmatter: AstroFrontmatter;
  content: string;
}

sync-obsidian.utils.ts

All the logic, needs some clean up but works for now. When ever I need more ill try to update this post.

import path from "path";
import fs from "fs";
import matter from "gray-matter";
import type { SyncConfig, AstroFrontmatter, ParsedNote } from "./sync-obsidian.types";

export function slugify(name: string): string {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

export function buildFrontmatter(data: Record<string, unknown>): AstroFrontmatter | null {
  if (data.obsidian !== true) return null;

  const title = data.title as string;
  const date = data.date as string;
  const type = data.type as AstroFrontmatter["type"];

  if (!title || !date || !type) return null;

  return {
    title,
    date: String(date),
    spoiler: (data.spoiler as string) || "",
    type,
    draft: (data.draft as boolean) ?? false,
    lang: (data.lang as "en" | "de") || "en",
    obsidian: true,
    ...(data.heroImage ? { heroImage: data.heroImage as string } : {}),
    ...(data.featured ? { featured: data.featured as boolean } : {}),
    ...(data.translationKey ? { translationKey: data.translationKey as string } : {}),
    ...(data.tags ? { tags: data.tags as string[] } : {}),
  };
}

export function transformImageEmbeds(content: string, slug: string, config: SyncConfig): string {
  let imageIndex = 0;

  return content.replace(/!\[\[([^\]]+)\]\]/g, (_match, inner: string) => {
    imageIndex++;

    const [filename] = inner.split("|");
    const ext = path.extname(filename);
    const newFilename = `${slug}-${imageIndex}${ext}`;
    const src = path.join(config.attachmentsPath, filename);
    const dest = path.join(config.imagesDir, newFilename);

    if (fs.existsSync(src)) {
      fs.copyFileSync(src, dest);
      console.log(`  Copied image: ${filename} -> ${newFilename}`);
    } else {
      console.warn(`  Image not found: ${src}`);
    }

    return `![${filename}](../../../images/obsidian/${newFilename})`;
  });
}

export function transformWikilinks(content: string, knownFiles: Set<string>): string {
  return content.replace(/\[\[([^\]]+)\]\]/g, (_match, inner: string) => {
    const [target, display] = inner.split("|");
    const displayText = display || target;
    const targetFile = target.trim();

    if (knownFiles.has(targetFile)) {
      return `[${displayText}](/posts/${slugify(targetFile)})`;
    }

    return displayText;
  });
}

export function transformHeadingLinks(content: string): string {
  return content.replace(/(?<![(\[`/])#([A-Z][A-Za-z0-9 ]+)/g, (_match, heading: string) => {
    const anchor = slugify(heading.trim());
    return `[${heading.trim()}](#${anchor})`;
  });
}

export function transformCallouts(content: string): string {
  const calloutStyles: Record<string, string> = {
    info: "border-warning shadow-warning",
    warning: "border-danger shadow-danger",
    note: "border-success shadow-success",
  };

  const calloutRegex = /^(?:>[ ]?\[!(\w+)\][ ]?(.*)?)\n((?:^>.*\n?)*)/gm;

  return content.replace(calloutRegex, (_match, type: string, title: string, body: string) => {
    const normalizedType = type.toLowerCase();
    const styles = calloutStyles[normalizedType] || calloutStyles.info;

    const bodyContent = body
      .split("\n")
      .map((line) => line.replace(/^>[ ]?/, ""))
      .filter((line) => line !== "")
      .join("\n");

    const titleHtml = title?.trim()
      ? `\n  <h3 class="text-2xl font-bold mb-4 font-sans text-midnight">${title.trim()}</h3>`
      : "";

    return `<div class="border-4 p-4 bg-white my-8 ${styles} relative">${titleHtml}
  <div class="prose prose-midnight">

${bodyContent}

  </div>
</div>

`;
  });
}

export function findMarkdownFiles(dir: string, base: string = ""): string[] {
  const results: string[] = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const relativePath = path.join(base, entry.name);
    if (entry.isDirectory()) {
      results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath));
    } else if (entry.name.endsWith(".md")) {
      results.push(relativePath);
    }
  }

  return results;
}

export function cleanSyncedPosts(config: SyncConfig): void {
  if (!fs.existsSync(config.outputDir)) {
    fs.mkdirSync(config.outputDir, { recursive: true });
    return;
  }

  const entries = fs.readdirSync(config.outputDir, { withFileTypes: true });

  for (const entry of entries) {
    if (!entry.isDirectory()) continue;

    const indexMd = path.join(config.outputDir, entry.name, "index.md");
    if (!fs.existsSync(indexMd)) continue;

    const raw = fs.readFileSync(indexMd, "utf-8");
    const { data } = matter(raw);

    if (data.obsidian === true) {
      fs.rmSync(path.join(config.outputDir, entry.name), { recursive: true });
    }
  }
}

export function cleanSyncedImages(config: SyncConfig): void {
  fs.rmSync(config.imagesDir, { recursive: true, force: true });
  fs.mkdirSync(config.imagesDir, { recursive: true });
}

export function transformFiles(files: string[], config: SyncConfig): ParsedNote[] {
  const knownFiles = new Set(files.map((f) => path.basename(f, ".md")));
  const posts: ParsedNote[] = [];

  for (const file of files) {
    const fullPath = path.join(config.gardenPath, file);
    const raw = fs.readFileSync(fullPath, "utf-8");
    const { data, content } = matter(raw);

    const frontmatter = buildFrontmatter(data);
    if (!frontmatter) continue;

    const slug = slugify(path.basename(file, ".md"));
    const withImages = transformImageEmbeds(content, slug, config);
    const withWikilinks = transformWikilinks(withImages, knownFiles);
    const withCallouts = transformCallouts(withWikilinks);
    const processed = transformHeadingLinks(withCallouts);

    posts.push({ slug, frontmatter, content: processed });
  }

  return posts;
}

export function writePosts(posts: ParsedNote[], config: SyncConfig): void {
  for (const post of posts) {
    const folder = path.join(config.outputDir, post.slug);
    fs.mkdirSync(folder, { recursive: true });

    const file = path.join(folder, "index.md");
    fs.writeFileSync(file, matter.stringify(post.content, post.frontmatter), "utf-8");

    console.log(`  Synced: ${post.slug}/index.md`);
  }
}

Further Reads