My personal weblog of posts, links, quotes, and more, rendered from an Obsidian vault by a small Clojure program using babashka.
I wanted authoring content to be as easy as adding to my personal wiki (in Obsidian), with the tooling handling the complexity of transforming all those files into a website. The build process handles parsing frontmatter, [[wikilinks]], pasted images, and organizing the content.
This project was written almost entirely by Claude (Opus 4.8 and Fable 5). See PLAN.md for the original AI-generated architecture and its rationale. More details about how it works are below, and instructions in case you ever want to clone this and up a similar website.
# install babashka ≥ 1.12.196 (https://github.com/babashka/babashka#installation), then:
bb dev # serve the vault configured in config/dev.edn at http://localhost:8100
bb test # run the test suite(To see the website with some realistic-looking fake data point :content-path in config/dev.edn at example-content.)
Two directories, and git never lives inside iCloud so that iCloud can't corrupt it.
iCloud vault (source of truth) Publish repo (transport, disposable)
~/…/Obsidian/Documents/Blog/ ~/code/projects/kirahowe-content/
├── drafts/ ← you write here ├── .git/ ← OUTSIDE iCloud
├── 2026/07/… ← published ├── 2026/07/…
├── attachments/ ← pasted images ├── attachments/
├── pages/ └── pages/
├── templates/, dev.md (never published) │
└── .obsidian/ (never published) │
│ │
│ iCloud ⇄ phone │ git push ⇄ GitHub ⇄ server
└──────────► bb publish / bb sync ─────┘
- The vault is a plain Obsidian vault in iCloud (so my phone can see it). It contains no
.gitso that nothing git-related ever syncs to the phone or gets mangled by iCloud. - The publish repo is a normal git checkout outside iCloud. It's a mirror of the vault's publishable content and is disposable: if it ever wedges, delete it and re-clone.
bb publish/bb syncmirror the vault into it and push, the server pulls from GitHub as usual. - My mac is the only bridge between the two sync systems, and only when I run
bb publish/bb sync.
One-time setup:
- In Obsidian, create a dedicated vault in iCloud (everything that gets
published ends up on the server). Its
.obsidian/and top-leveldev.md/templates/stay private — only the date tree,pages/, andattachments/are mirrored out. - Make a git checkout outside iCloud and push it once (public — the
server pulls it anonymously):
mkdir -p ~/code/projects/kirahowe-content && cd $_ git init -b main printf '.DS_Store\n' > .gitignore && git add -A && git commit -m "init" gh repo create kirahowe-content --public --source . --push
- In
config/dev.edn, point:content-pathat the vault and:publish-repoat that checkout. Inconfig/prod.edn, point:content-git-urlat the repo. Thenbb dev, andbb publish/bb syncto push content live.
iCloud tip: right-click the vault folder → "Keep Downloaded" so iCloud keeps a real local copy instead of evicting files to placeholders.
- New file in
drafts/. The filename is the title. A bare file with no frontmatter publishes as a post — frontmatter is entirely optional.bb newscaffolds it from the vault'stemplates/folder; on the phone, Obsidian's Insert template command does the same — pick the type and its properties (includingdateandpublish, below) are prefilled. - Properties, not metadata. Frontmatter is YAML — Obsidian's
Properties panel.
tagsautocomplete against the vault; other entry types settype: note/type: link/type: quoteplus their natural fields (link,via,author,source). dateandpublishare workflow properties, not entry data — the site itself ignores both (a published entry's date is its folder path).dateis set to today when the draft is created, but it's yours to edit (e.g. to backdate something written over several days);bb publishfiles the entry under it, falling back to today if it's missing.publishstartsfalse; see "Publishing from your phone" below for what flipping it does.- Link with
[[wikilinks]]. They resolve by filename to the entry's URL at render time. An unresolved link (e.g. to a still-unpublished draft) degrades to plain text — never a dead link — andbb publishwarns about it. - Paste images. Obsidian files them under
attachments/(the vault is preconfigured); the server serves them at/attachments/.... An image never requires a site deploy. - Slugs are automatic:
slugify(filename). Aslug:property exists only to pin a URL (e.g. one inherited from an old blog). - Content that lived (or lives) elsewhere. Two properties keep the
web tidy.
canonical:marks a cross-post whose canonical home is elsewhere: the page emits arel=canonicalpointing there and credits the original visibly ("originally published at …").previously:lists URLs the content used to live at: paths on this site's own domain 301 to the entry automatically (consulted only where the site would otherwise 404, so an old URL can never shadow live content), andbb redirectsexports the foreign-domain ones as a Cloudflare bulk-redirects CSV to serve from wherever the old domain now points.
bb new post My great idea # scaffolds drafts/My great idea.md
# ...write — with `bb dev` running, preview at localhost:8100/drafts/My great idea
bb suggest-tags my-great-idea # LLM-suggested tags, printed ready to paste
bb reindex # optional: validate that everything parses
bb drafts # see every draft, and which are queued (publish: true)
bb publish my-great-idea # lints, moves it into its date folder, mirrors + pushesDon't feel like typing a name? Run these with no argument and they hand
you a menu instead (↑/↓ or j/k, Enter to pick, q/Esc to cancel):
bb new picks an entry type (and then asks for an optional title),
bb suggest-tags lists every draft or published entry that still has no
tags (a post can go out untagged and want tags after the fact), and bb publish lists every draft — with a shortcut at the top to flush the whole
queue at once. It's a tiny pure-babashka picker (src/site/tui.clj), no
gum/fzf to install.
A file is a draft because it lives in drafts/; publishing is moving it
into the date tree. No flags to forget. bb publish warns about
unresolved wikilinks, missing attachments, missing or never-seen tags, a
link entry without a URL, and a post short enough to be a note — but a
warning never blocks a publish.
Run it with no name at a terminal and it offers the picker above; with no
name in a script (or the launchd agent) it publishes every queued draft
instead (see "Publishing from your phone" below) — bb drafts shows what
that would do.
Editing or deleting something already published? Change it in Obsidian,
then bb sync — it re-mirrors the vault into the publish repo and pushes,
deletions included. bb publish does this automatically for the draft it
publishes; bb sync is for everything else.
bb publish is the manual publish. The live site picks the push up
on its next timed pull (≤ :content-sync-seconds); to go live right
now, fly apps restart <app> — bb publish prints this command for you.
Writing happens anywhere the vault syncs to — including the phone — but
publishing still needs a git push, which only the Mac can do. The bridge
is the publish property: flip it to true in Obsidian on your phone to
queue a draft, and a launchd agent on the Mac notices and publishes it.
The agent is optional, though — a bare bb publish at the Mac's terminal
lets you pick a draft (or the flush-the-queue shortcut) by hand, any time.
bb autopublish install # once, on the Mac, from the project rootThat installs a user launchd agent that runs bb publish-queued every
5 minutes: it publishes every queued draft under its authored date
(oldest first), mirrors, and pushes — the same as running bb publish
by hand, just unattended. A draft with a broken date property is warned
about and skipped rather than blocking the rest of the queue, and the
agent refuses to publish anything at all if the content tree doesn't
index cleanly (the same stance bb sync takes).
A few things follow from that being a Mac-local agent, not a server:
- The Mac has to be awake and signed into iCloud for the vault to
have synced down the flipped
publishproperty in the first place. - Pushes use your normal git credentials — an SSH key loaded into Keychain works fine under launchd.
- Once pushed, it's live within the usual content-sync window
(≤
:content-sync-seconds), same as any other publish. - Logs land at
~/Library/Logs/website-autopublish.log. bb autopublish statusshows whether it's installed and running, plus the tail of the log;bb autopublish uninstallremoves it.
Whatever :content-path points at:
my-vault/
├── drafts/ # all writing starts here (flat — phone-friendly)
│ └── An idea brewing.md
├── pages/ # static pages → /about, etc.
│ └── about.md
├── attachments/ # pasted images → served at /attachments/...
│ └── screenshot.png
└── 2026/07/04/ # published entries; the path IS the date
└── Hello world.md # → /2026/jul/4/hello-world
Frontmatter, when an entry needs any (a plain post doesn't):
---
type: link
link: https://example.com/article
via: https://news.ycombinator.com/item?id=1
tags:
- clojure
slug: custom-slug
---
Body in markdown, with [[Hello world|wikilinks]] and ![[screenshot.png]].typedefaults topost; a typo'd type fails indexing loudly- a
noteis one thought, short enough that the feed is the page: it publishes whole there, links and emphasis live, with no "…n min read" onward link (a post previews its first paragraph and offers the rest —bb publishwarns when a post is short enough to wanttype: note) - quotes use
author:andsource:(the URL), and stay untitled unless atitle:property says otherwise canonical:marks a cross-post whose canonical home is elsewhere;previously:lists URLs the content used to live at (see "Writing")- the original EDN frontmatter (
;;;-delimited) is still accepted
Three committed files under config/, no environment variables, no secrets:
config/config.edn— the base that always applies: site title, base URL, entry types,:llm-command(whatbb suggest-tagsshells out to).config/dev.edn— merged in bybb devand the authoring tasks: your vault path,:content-git-url nil(no git syncing locally), personal:llm-commandoverride,:port 8100.config/prod.edn— merged in bybb prod: the clone target, the content repo URL, the sync interval,:port 8080(what Fly routes to).
The port is environment-specific, so dev and prod never collide on one.
Dev-only behavior (draft previews at /drafts/<name>, per-request
reindexing) follows the environment, so dev and prod can't drift apart.
The production server exposes no admin surface at all: it just pulls the
content repo on a timer.
| URL | Shows |
|---|---|
/2026 · /2026/jul · /2026/jul/4 |
date archives |
/2026/jul/4/hello-world |
single entry |
/posts · /notes · /links · /quotes · /releases · /tools |
by type (/2026/posts filters by year) |
/tags · /tags/clojure · /tags/clojure/2026 |
by tag |
/search?q=... |
full-text search |
/feed.xml |
RSS |
/attachments/... |
images from the vault |
Dockerfile and fly.toml are included. prod.edn holds the content
repo settings:
{:content-path "content" ; clone target
:content-git-url "https://github.com/<you>/<content-repo>"}The machine clones the content repo at boot and pulls every
:content-sync-seconds (default 300), reindexing when anything changed —
so publishing is just a git push; content changes never require a deploy.
# once:
fly launch --copy-config --no-deploy # then edit `app` in fly.toml if taken
# every code change:
fly deploy- Every public page gets CDN-friendly cache headers; put Cloudflare (or any CDN) in front and traffic spikes never reach the server.
- Publishes go live within
:content-sync-seconds(default 5 minutes) of the git push — no deploy, no webhook, no admin endpoint. - A failed sync can never take the site down: bad network or a broken content push is logged, the last good index keeps serving, and the server retries every tick until the content is fixed. Even a broken clone at boot serves an empty site rather than crash-looping.
Running anywhere else is the same idea without the Fly wrapper: the same
config files, and just bb prod.