← Back

Obsidian to Blog

my Obsidian vault is my second brain - it has everything from work notes to random ideas to blog posts and I always wanted the blog posts to just… be the blog not copy-paste into some CMS, not export to Hugo, not maintain two copies of the same thing just write in Obsidian, push to git, and it’s live

so that’s exactly what I built

the idea

the repo is both an Obsidian vault and an Astro project at the same time the root of the repo is the vault - .obsidian/ folder and all and inside there’s an astro/ directory that knows how to build the vault into a static site

the key insight is in the content config:

const content = defineCollection({
  loader: glob({
    pattern: [
      "**/*.md",
      "!node_modules/**",
      "!astro/**",
      "!dist/**",
      "!**/.*/**",
    ],
    base: "..",
  }),
});

Astro’s glob loader just scans the parent directory (the vault root) for all markdown files it ignores node_modules, the astro folder itself, dist, and dotfiles everything else is fair game

obsidian flavored markdown

the one tricky part is that Obsidian has its own markdown flavor two things needed fixing - image embeds and comments

Obsidian does images like ![[photo.png]] instead of standard ![](photo.png) and it has comment blocks with %% hidden stuff %%

so I wrote two tiny remark plugins that handle both:

// Converts ![[file.png]] to standard markdown images
export const remarkObsidianImages: Plugin<[], Root> = () => {
  return (tree) => {
    visit(tree, "paragraph", (node) => {
      for (let i = 0; i < node.children.length; i++) {
        const child = node.children[i];
        if (child.type !== "text") continue;
        const match = child.value.match(/!\[\[([^\]]+)\]\]/);
        if (!match) continue;
        node.children[i] = { type: "image", url: `/${match[1]}`, alt: match[1] };
      }
    });
  };
};

// Strips %% comment %% blocks
export const remarkObsidianComments: Plugin<[], Root> = () => {
  return (tree) => {
    visit(tree, "text", (node) => {
      node.value = node.value.replace(/%%[^]*?%%/g, "");
    });
  };
};

images that live in the vault root get copied to public/ during the build so Astro can serve them

public vs private

not everything in my vault should be public obviously in CI only specific sections get built:

const ci = !!process.env.CI;
const PUBLIC_SECTIONS = ["blog", "talks"];

const all = (await getCollection("content"))
  .filter((entry) => {
    if (!ci) return true;
    const section = entry.id.split("/")[0]?.toLowerCase();
    return PUBLIC_SECTIONS.includes(section);
  });

locally I see everything - all my notes, projects, thoughts but when it builds on GitHub Actions only Blog/ and talks/ make it to production so I can keep private notes in the same vault without worrying about leaking them

the deploy

push to master and GitHub Actions does the rest:

  1. bun install
  2. bun run build (copies images, runs Astro build)
  3. deploys to Cloudflare Pages via wrangler

the whole build is like 10 seconds

why this works

the beauty of this setup is there’s almost nothing to maintain no static site generators with their own content directories and config files no syncing between an editor and a build system the vault IS the source of truth

I write a new markdown file in Obsidian, add a frontmatter with title and date, commit and push that’s it, it’s a blog post I don’t even need to leave Obsidian - the obsidian-git plugin lets me commit and push right from the editor

and because it’s just markdown files in a git repo, Claude Code can write and edit posts too which is exactly how this blog post was written