A CLI tool that converts an Obsidian vault (or any directory of markdown files) into a static HTML site with a cyberpunk aesthetic, ready for deployment to GitHub Pages.
Paulblish (pb) takes a directory of markdown files — such as an Obsidian vault — and generates a complete static HTML site from it. The generated output is committed directly to the repo and deployed via GitHub Pages. Your source vault lives on your machine; only the HTML output is in version control.
The project follows the file over app philosophy. Your content stays in plain markdown, and the site generator is just a tool you run locally.
git clone https://github.com/phalt/paulblish.git
cd paulblish
make install
uv run pb build --source ~/obsidian/blog --output ./_site
git add _site/
git commit -m "Rebuild site"
git pushClone the repo and install dependencies using uv:
git clone https://github.com/phalt/paulblish.git
cd paulblish
make installBuild the static site from a source directory.
uv run pb build --source ~/obsidian/blog --output ./_site| Flag | Default | Description |
|---|---|---|
--source, -s |
. (cwd) |
Path to the markdown source directory. Must contain a site.toml. |
--output, -o |
./_site |
Path to write generated HTML. |
--base-url |
(from site.toml) | Base URL for absolute links (overrides site.toml). |
--templates |
(bundled defaults) | Path to a custom Jinja2 templates directory. |
--drafts |
false |
Include articles without publish: true. |
--incremental |
false |
Only rebuild articles whose source file has changed since the last build. See Incremental Builds. |
Remove the output directory.
uv run pb clean --output ./_site| Flag | Default | Description |
|---|---|---|
--output, -o |
./_site |
Path to the built site directory to remove. |
Serve the built site locally for preview.
uv run pb serve --output ./_site
uv run pb serve --output ./_site --port 9000| Flag | Default | Description |
|---|---|---|
--output, -o |
./_site |
Path to the built site directory to serve. |
--port, -p |
8000 |
Port to listen on. |
Site configuration is loaded from one of two sources, tried in order:
site.toml— a TOML file in the root of your source directory (preferred).Home.mdfrontmatter — YAML frontmatter fields inHome.mdat the source root (useful for Obsidian users where.tomlfiles are inconvenient).
If site.toml is present it takes priority. If neither source is found, or the required fields are missing, pb build exits with an error:
Error: No site configuration found in <source directory>
Either create a site.toml file or add site config fields to your Home.md frontmatter:
title, base_url, description, author
Create site.toml in the root of your source directory:
[site]
title = "My Blog"
base_url = "https://yourusername.github.io/yourrepo"
description = "A blog about things."
author = "Your Name"
cname = "" # optional — your custom domain, e.g. "blog.example.com"
avatar = "" # optional — relative path to a square image for the home pageAdd the config fields to the YAML frontmatter of your Home.md:
---
publish: true
title: "My Blog"
base_url: "https://yourusername.github.io/yourrepo"
description: "A blog about things."
author: "Your Name"
cname: "" # optional
avatar: "" # optional
---| Field | Required | Description |
|---|---|---|
title |
yes | Site title shown in <title> and the nav bar |
base_url |
yes | Absolute base URL (e.g. https://user.github.io/repo) |
description |
yes | Short site description for <meta> tags |
author |
yes | Author name shown in footer and meta |
cname |
no | Custom domain — writes a CNAME file to the output root |
avatar |
no | Path to a square image shown on the home page |
Only files with publish: true in their frontmatter are included in the build.
---
publish: true # required — must be true to be included
title: "Article Title" # optional — derived from first H1 or filename if absent
slug: "article-title" # required — used as the URL segment; also accepts `permalink`
date: 2026-03-15 # optional — used for sorting; falls back to file mtime
tags: [python, tooling] # optional — list of strings; generates /tags/{tag}/ pages
description: "A short summary." # optional — shown in article header and listings
---Files missing a slug (or permalink) are skipped with a clear reason in the build output.
The source directory path of each file is preserved in the output URL:
| Source file | Output path | URL |
|---|---|---|
foo.md |
_site/foo/index.html |
/foo/ |
articles/foo.md |
_site/articles/foo/index.html |
/articles/foo/ |
articles/deep/bar.md |
_site/articles/deep/bar/index.html |
/articles/deep/bar/ |
Home.md |
_site/index.html |
/ |
A file named Home.md (case-insensitive) at the root of your source directory becomes the site index page at /.
The home page renders with:
- An ASCII art "Hello" banner (
<pre class="ascii-banner" aria-hidden="true">) - An optional avatar image (configured via
site.avatarinsite.toml) - The body content of
Home.md
If no Home.md is present or it is not published, the index page falls back to a generated article listing.
The deployment workflow is: build locally → commit _site/ → push → GitHub Actions deploys.
There is no build step in CI. You build the site on your machine and commit the generated HTML.
-
Build the site locally:
uv run pb build --source ~/obsidian/blog --output ./_site -
Commit the output:
git add _site/ git commit -m "Rebuild site" git push -
In your GitHub repo settings, go to Settings → Pages and set the source to GitHub Actions.
The included deploy.yml workflow triggers on any push to main that touches _site/** and deploys the directory to GitHub Pages.
The base_url in your site.toml controls how all internal links and asset paths are generated. The correct value depends on how your site is hosted.
Your site lives at https://username.github.io/reponame/. Set base_url to the full path including the repo name:
base_url = "https://username.github.io/reponame"When you use a CNAME, your site is served from the root of your domain. Set base_url to just the domain — no trailing slash, no path suffix:
base_url = "https://blog.example.com"
cname = "blog.example.com"pb serve handles this automatically. On every pb build, a small metadata file (.pb-meta.json) is written to the output directory recording the base_url that was used. When you then run pb serve, the server reads that file and rewrites all occurrences of base_url in HTML and XML responses to an empty string before sending them to the browser. Internal paths like /articles/foo/ are not affected.
uv run pb build --source ~/obsidian/blog --output ./_site # build once with production base_url
uv run pb serve # works locally without any rebuildThe production _site/ files themselves are never modified; rewriting happens only in-flight during serving.
Set cname in your site.toml:
cname = "blog.example.com"This writes a CNAME file to _site/CNAME on every build. GitHub Pages reads the CNAME from the published directory root — no manual setup needed beyond pointing your DNS.
For large vaults, re-rendering every article on each build can be slow. Pass --incremental to skip articles whose source file has not been modified since the last build:
uv run pb build --source ~/obsidian/blog --output ./_site --incrementalHow it works:
- At the end of every build (full or incremental) a manifest file
.pb-manifest.jsonis written to the output directory. It records each article's source path and its modification time. - On the next
--incrementalbuild, articles whose sourcemtimematches the manifest are skipped — their existing HTML files are left untouched. - Articles that have been modified (or are new) are fully re-rendered.
- Source files that have been deleted since the last build have their output HTML removed automatically.
- Shared pages (all-articles listing, tag pages, RSS feed,
sitemap.xml,robots.txt,404.html) are always regenerated — they reflect the full article set.
--incremental is compatible with --drafts. Draft articles are tracked in the manifest when --drafts is active.
make install # uv sync — install all dependencies
make test # uv run pytest
make lint # uv run ruff check .
make format # uv run ruff format .
make clean # remove _site/, __pycache__, and egg-infoPaulblish is designed so anyone can fork it and run their own blog. To set up your own:
-
Fork this repository (or use "Use this template" on GitHub).
-
Clone it locally and run
make install. -
Configure your site — pick whichever suits your workflow:
Option A —
site.toml(create in the root of your Obsidian directory):[site] title = "My Blog" base_url = "https://yourusername.github.io/yourrepo" description = "A blog about things." author = "Your Name" cname = "" # set to your custom domain, or leave empty avatar = "" # path to a square image, or leave empty
Option B —
Home.mdfrontmatter (add fields to your existingHome.md):--- publish: true title: "My Blog" base_url: "https://yourusername.github.io/yourrepo" description: "A blog about things." author: "Your Name" ---
-
Ensure your markdown files have
publish: truein their frontmatter. -
Create a
Home.mdin the root of your content directory for your index page. -
Build the site:
uv run pb build --source /path/to/your/obsidian/dir --output ./_site
-
Commit the
_site/directory and push tomain. -
In your GitHub repo settings, enable Pages and set it to deploy from GitHub Actions.
The pb tool, templates, and styles are all included in the repo. Customise the templates in templates/ and the CSS in templates/static/style.css to make it your own.