Jacob Kaplan-Moss

Today I Learned…

Publishing an Obsidian vault with Hugo

Here’s one way I figured out to publish an Obsidian vault as a static website using Hugo. This is a hard way: the easy way is to spend $100/yr on Obsidian Publish.

My requirements

I want to:

  • Edit my content in Obsidian.
  • Design and publish the web version with Hugo. I want to use Hugo but only because it’s the tool I know. I’m not sure it’s actually the best tool for this – as you’ll see below it has some warts that makes this more difficult than it might with other static site generators. But I know Hugo pretty well, and am happy enough with it despite its warts.
  • Not have to worry about correct Hugo frontmatter (dates, titles, etc). I can edit these in Obsidian via its Properties feature, but I want that to be optional. I want to be able to work on stuff in Obsidian and not thing about the Hugo part at all (unless I want to).
  • Publish automatically. I don’t want an explicit “commit” or “push” or “publish” step; I just want to write and have stuff appear on the web.

The components I chose

The various pieces I knitted together to make this happen are:

obsidian-git
Automate pushing from my vault to a Github repo
Templater
More powerful templates in Obsidian. I’m not using this very heavily yet, but it’s the tool I’ll use to ensure consistent frontmatter when and if I want to start using it more.
obsidian-export
Converts vault documents from Obsidian’s Markdown variant into “plain” markdown. Also adds empty frontmatter where it’s missing to prevent Hugo from complaining about files without frontmatter.
make-index-files
A small script I wrote to add missing _index.md files. See below.
Husky
Manage a pre-commit hook to run obsidian-export on commit. There are a million other ways to manage pre-commit hooks, Husky is nice enough. I already need a working NPM setup because I’m using Tailwind and PostCSS, so using something from the Node ecosystem here isn’t a problem.
Cloudflare Pages
Where I’m publishing. I have no love for Cloudflare but their product is fast and free and stable, and I don’t dislike their CEO quite enough to have cause to look elsewhere.

Plus of course Hugo, Obsidian, Github…

How it fits together

  1. I have a Hugo site as a GitHub repo. It’s a normal Hugo site, nothing special here.

  2. In that repo, I have a vault folder, which contains my Obsidian vault. I point Obsidian at that vault for editing.

  3. obsidian-git is set up to auto-commit and auto-push changes to the vault. The key settings are:

    • Advanced -> Custom base path: ... This is the key setting that tells obsidian-git that my vault folder is actually a subdirectory of the root git repo. Without this setting, pushes will fail.
    • Backup interval: 5 minutes. Nothing special about 5 minutes, but this setting has to be set to something greater than 0 for auto-commits to happen.
  4. A pre-commit script (managed by Husky) that converts the vault:

    rm -rf content/*
    bin/obsidian-export vault/ content/ --frontmatter always
    bin/make-index-files content/
    git add -A content/
    

    This:

    • blows away the existing site content
    • converts the obsidian content in vault/ to plain markdown in content/, adding frontmatter
    • makes missing _index.md files, see below
    • adds the converted content/ dir to the in-flight git commit

    Thus, when obsidian-git auto-commits, this script is run, and a converted site it pushed to github.

  5. Cloudflare pages is configured to watch this repo, and build it as a Hugo site.

I could have chosen to run the conversion steps as part of the Cloudflare build. There’s no particular reason I did it as a pre-commit hook, but I do like that it means I can very easily publish this anywhere else.

Adding missing _index.md files

The last missing piece for me was adding missing _index.md files. This isn’t necessary if you don’t have multiple levels of content nesting, but I do, and so Hugo needs a little extra help here.

Hugo only partially translates your source content hierarchy into a site hierarchy. Hugo considers any top-level directories as “sections” and creates hierarchy for these, but doesn’t automatically do anything with deeper nesting. That is, if you have content/aaa/one.md and content/bbb/two.md, Hugo gets this, and your built site will have aaa and bbb sections, and your two documents will have URLs like example.com/aaa/one/ and exmaple.com/bbb/two/. But, if you then add content/aaa/ccc/three.md, that document will be considered part of the aaa section, and will have a URL like example.com/aaa/three.md – the ccc subdirectory vanishes!

The way you tell Hugo that you want to preserve this deeper nesting is by adding an _index.md file. This forces Hugo to consider that directory a “section”. So, if you add content/aaa/ccc/_index.md, Hugo now considers aaa/ccc a “section”, and your three.md document will be at example.com/aaa/ccc/three/.

I don’t want to have to think about this, so I wrote this little script to do it as part of the pre-commit hook:

#!/usr/bin/env python

import sys
from pathlib import Path

def make_indexes(containing_dir: Path):
    for dir, _, _ in containing_dir.walk():
        # don't create a top-level _index
        if dir == containing_dir:
            continue

        index_path = dir / "_index.md"
        if not index_path.exists():
            index_path.write_text(f"---\ntitle: {dir.name.title()}\n---\n")

if __name__ == "__main__":
    make_indexes(Path(sys.argv[1]))