Turn your markdown directory into a static site
Project description
Paulblish 📖
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.
What is this?
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.
Quick Start
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 push
Installation
Clone the repo and install dependencies using uv:
git clone https://github.com/phalt/paulblish.git
cd paulblish
make install
Usage
pb build
Build 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. |
pb clean
Remove the output directory.
uv run pb clean --output ./_site
| Flag | Default | Description |
|---|---|---|
--output, -o |
./_site |
Path to the built site directory to remove. |
pb serve
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
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
Method 1: site.toml
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 page
Method 2: Home.md frontmatter
Add 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
---
Fields
| 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 |
Frontmatter Schema
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.
Directory Structure
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 |
/ |
The Home File
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.
Deployment
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.
Steps
-
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.
Base URL patterns
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.
Pattern 1: GitHub Pages without a custom domain
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"
Pattern 2: GitHub Pages with a custom domain (CNAME)
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"
Testing locally
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 rebuild
The production _site/ files themselves are never modified; rewriting happens only in-flight during serving.
Custom Domain
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.
Incremental Builds
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 --incremental
How 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.
Development
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-info
Fork Your Own Copy
Paulblish 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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file paulblish-0.3.0.tar.gz.
File metadata
- Download URL: paulblish-0.3.0.tar.gz
- Upload date:
- Size: 790.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eef784ca6b7f8738b84fd1eb108a2a41f536a03dee3a8cb3650c9bf8f98ba549
|
|
| MD5 |
0c351ee43a758bab9fc08a44ab272bf5
|
|
| BLAKE2b-256 |
adff077820cc31a578d44b1c67921507f83dd9f7b96cd5857fde4e55e4cf1d43
|
File details
Details for the file paulblish-0.3.0-py3-none-any.whl.
File metadata
- Download URL: paulblish-0.3.0-py3-none-any.whl
- Upload date:
- Size: 28.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
54851b9f8b75c36f3916f4fc7e4778758c29ccc89fedce44d44f71054c93a14f
|
|
| MD5 |
d073dd404f182858a0f0ea944c11faf5
|
|
| BLAKE2b-256 |
30e43405b5eaf4b202b69d756cf719eab9054f5437d99395198690b5d6a6b412
|