Paulblish (pb) is a CLI tool that converts an Obsidian vault directory into a static HTML site, suitable for deployment to GitHub Pages. It is, obviously, a pun.
- Replace Obsidian Publish with a self-hosted, zero-cost alternative.
- Generate standard HTML from Obsidian-flavoured markdown.
- Run as a CLI tool (
pb) pointed at any directory — no Obsidian dependency at runtime. - Produce clean, addressable URLs that preserve the source directory structure.
- Support a simple local-build workflow: run
pb buildlocally, commit the output, push to deploy.
- Live preview or dev server (out of scope for v1; use any static file server).
- Dataview query execution (queries are stripped, not evaluated).
- Theme compatibility with Obsidian community themes.
- WYSIWYG editing or CMS features.
- JavaScript-heavy client-side rendering.
- Running the build in CI — the build runs on the developer's machine.
The project must use phalt/clientele as the structural reference for all decisions about project layout, tooling, and conventions. Specifically:
- Package layout: Flat layout with the package directory at the repo root (
paulblish/), not asrc/layout. - Dependency management:
uvfor all dependency management.uv.lockcommitted to the repo..python-versionfile pinning the Python version. - Build backend:
hatchlingas the build backend inpyproject.toml. - Makefile: A
Makefileproviding common development tasks (make install,make test,make lint,make build,make clean, etc.). - Linting / formatting:
rufffor linting and formatting, configured inpyproject.tomlunder[tool.ruff]. - Tests:
tests/directory at the repo root.pytestas the test runner. - Documentation files:
README.md,CHANGELOG.md,CONTRIBUTING.md,LICENSEat the repo root. - CI:
.github/workflows/for GitHub Actions.
Any time there is a question about "how should this be structured?" or "which tool should we use?", the answer is: do it the way clientele does it.
A markdown file is considered publishable if all of the following are true:
- It lives within the configured source directory (recursive).
- It has YAML frontmatter containing
publish: true. - It has a
.mdextension.
Files without frontmatter, or with publish: false / missing publish key, are ignored entirely.
---
publish: true # required — boolean, must be true
title: "Article Title" # optional — derived from H1 or filename if absent
slug: "article-title" # required — permalink is accepted as an alias
date: 2026-03-15 # optional — used for sorting, falls back to file mtime
tags: [python, tooling] # optional — list of strings
description: "A short summary." # optional — used in <meta> and listing page
---Resolution order for title:
- Frontmatter
title - First
# H1heading in the document body - Filename (without
.md, deslugified)
Resolution order for slug:
- Frontmatter
slug - Frontmatter
permalink(alias for slug) - Skip — file is not published (no automatic derivation from filename)
Resolution order for date:
- Frontmatter
date - File modification time (
mtime)
All resolved frontmatter metadata must be rendered visibly on each article page. The article template displays:
- Title — as the page
<h1>. - Date — formatted as a human-readable string (e.g.
15 March 2026), displayed below the title. - Tags — rendered as a list of styled inline labels, each linking to the corresponding tag index page. Displayed below the date.
- Description — if present, rendered as a subtitle/lead paragraph below the title block, before the article body. Also used in
<meta name="description">.
This metadata block is a distinct visual section at the top of every article, styled consistently via the article template.
The directory structure of the source vault is preserved in the output URL paths. The slug is always relative to the source root.
Examples:
Source file (relative to --source) |
Output path | URL |
|---|---|---|
foo.md |
output/foo/index.html |
/foo/ |
articles/foo.md |
output/articles/foo/index.html |
/articles/foo/ |
articles/deep/bar.md |
output/articles/deep/bar/index.html |
/articles/deep/bar/ |
Home.md |
output/index.html |
/ (special case) |
The slug (from frontmatter slug or permalink) replaces only the filename portion. The parent directory path is always preserved. This means the Article model needs a path_prefix field representing the relative directory path.
The slug (or its alias permalink) replaces only the filename portion; the directory prefix is always preserved. For example, articles/my-draft.md with slug: my-post produces /articles/my-post/.
The file named Home.md (case-insensitive match) in the root of the source directory is treated as the site index page. It is rendered to output/index.html instead of output/home/index.html.
If Home.md has publish: true, its content becomes the index page body. If it is absent or unpublished, the index page falls back to a generated article listing sorted by date (newest first).
Home.md is still included in the "All Pages" listing (see §6.4).
@dataclass
class Article:
source_path: Path # absolute path to the .md file
relative_path: Path # path relative to source root (e.g. articles/foo.md)
path_prefix: str # directory portion (e.g. "articles", "articles/deep", or "")
title: str
slug: str # filename portion only (e.g. "foo")
url_path: str # full URL path (e.g. "/articles/foo/")
date: datetime
tags: list[str]
description: str
body_markdown: str # raw markdown, frontmatter stripped
body_html: str # populated after rendering
is_home: bool # true if this is the Home file
assets: list[Path] # referenced images/files
@dataclass
class SiteConfig:
title: str
base_url: str
description: str
author: str
cname: str # custom domain for GitHub Pages, or "" if none
avatar: str # path to square avatar image, or "" if nonemarkdown-it-py with custom plugins for each Obsidian extension. Plugins are applied as a chain — standard markdown is handled by the base parser; Obsidian-isms are layered on top.
| Syntax | Example | Output | Priority |
|---|---|---|---|
| Standard CommonMark | **bold**, [link](url) |
Native markdown-it-py |
P0 |
| YAML frontmatter | ---\ntitle: ...\n--- |
Stripped (parsed separately) | P0 |
| Wikilinks | [[Note Name]] |
<a href="/note-name/">Note Name</a> |
P0 |
| Wikilinks with alias | [[Note Name|display]] |
<a href="/note-name/">display</a> |
P0 |
| Image embeds | ![[photo.png]] |
<img src="/assets/photo.png"> |
P0 |
| Note embeds | ![[Other Note]] |
Inline rendered HTML of target note | P1 |
| Callouts | > [!note] Title |
<div class="callout callout-note"> |
P1 |
| Highlights | ==text== |
<mark>text</mark> |
P1 |
| Fenced code blocks | ```python |
<pre><code> with Pygments highlighting |
P0 |
| Mermaid blocks | ```mermaid |
<pre class="mermaid"> (client-side JS) |
P2 |
| LaTeX inline | $E=mc^2$ |
<span class="math"> (KaTeX client-side) |
P2 |
| LaTeX block | $$\sum_{i=1}^{n}$$ |
<div class="math"> (KaTeX client-side) |
P2 |
| Dataview blocks | dataview ... |
Stripped entirely with optional warning comment | P2 |
| Tags in body | #sometag |
<span class="tag">sometag</span> or stripped |
P2 |
| Footnotes | [^1] |
<sup> / <section class="footnotes"> |
P1 |
Priority key: P0 = required for v1, P1 = important for usability, P2 = nice-to-have.
A path lookup table is built during the scan phase:
# Maps normalised note name -> url_path for all published articles
path_map: dict[str, str] = {
"note name": "/articles/note-name/",
"another post": "/deep/another-post/",
}Resolution rules:
- Normalise the wikilink target: strip
.md, lowercase, strip leading/trailing whitespace. - Look up in
path_map. - If found → render as
<a href="{url_path}">display text</a>. - If not found (target is unpublished or doesn't exist) → render as
<span class="wikilink-dead">display text</span>(plain text, no link, visually distinct via CSS).
Fenced code blocks with a language identifier are highlighted at build time using Pygments. The output uses CSS classes (not inline styles) so the colour scheme is controlled by the site stylesheet. The Pygments theme must complement the cyberpunk colour palette (see §6.1).
The site uses a cyberpunk / brutalist web 2.0 aesthetic inspired by the muted, worn tones of Cyberpunk 2077's UI — Night City grime, not arcade neon. The following design constraints apply:
Colour palette:
| Role | Colour | Hex | Usage |
|---|---|---|---|
| Background | Near-black, cool-tinted | #0a0a0f |
Page background |
| Surface | Dark charcoal | #14141f |
Code blocks, callout backgrounds, card surfaces |
| Primary text | Soft cool grey | #b8b8c0 |
Body text, readable at length |
| Bright text | Off-white | #e0dfd5 |
Headings, article titles, emphasis |
| Muted teal | Desaturated cyan | #5e9e91 |
Links, primary accent, nav highlights |
| Muted amber | Dusty gold | #b89c4a |
Tags, secondary accent, hover states |
| Muted rose | Dark crimson | #7a3b4e |
Dead wikilinks, warnings, error states |
| Border | Dark steel | #2a2a35 |
Dividers, borders, grid lines |
| Subtle highlight | Deep teal | #1a2a28 |
Hover backgrounds, active states |
Accent colours are used sparingly for interactive elements and structural markers — never for body text. The overall feel should evoke dusty neon signage seen through rain, not a rave.
Typography:
- Headings and UI elements: monospace font stack (
'JetBrains Mono', 'Fira Code', 'Courier New', monospace). - Body text: sans-serif (
'Inter', 'system-ui', sans-serif). Size 16–18px, line-height 1.6–1.7 for readability.
Layout:
- Brutalist grid sensibility. Visible borders, sharp corners (no border-radius). Generous use of whitespace.
- Max content width ~720px for article text.
Component styles:
- Links: Muted teal (
#5e9e91), no underline by default, underline on hover. Visited links use a slightly desaturated variant. - Code blocks: Surface background (
#14141f), monospace text. Syntax highlighting via Pygments using a palette-aligned theme (e.g.monokaiwith adjusted token colours). - Tags: Small bordered pills with muted amber text and border (
#b89c4a). - Callouts: Surface-coloured boxes with a muted teal left border.
- Article metadata block: Clearly separated from body text with a border-top or distinct surface background.
- Dead wikilinks: Muted rose (
#7a3b4e), dashed underline, to indicate a broken link without being distracting.
The design must remain readable first. The cyberpunk aesthetic is expressed through colour choices, typography, and structural elements — not through illegibility, excessive animation, or visual noise.
Jinja2 with a small template set.
| Template | Purpose |
|---|---|
base.html |
HTML shell: <head>, meta tags, nav, footer, CSS link. Defines {% block content %}. |
_nav.html |
Navigation bar partial: site title link + "All Pages" link. Included by base.html. |
article.html |
Extends base.html. Renders a single article with metadata block (title, date, tags, description) and `body_html |
home.html |
Extends base.html. Renders the home page: ASCII banner, avatar (if configured), then Home.md body content. |
listing.html |
Extends base.html. Renders a list of articles (used for tag pages). |
all_pages.html |
Extends base.html. Full listing of every published page, grouped by directory path. |
The site generates a dedicated "All Pages" page at /all/index.html that lists every published page, including the Home page.
Pages are grouped by their directory path, with the path used as a heading. Pages at the root level are grouped under a / heading. Within each group, pages are sorted by date descending.
Example rendered output:
# All Pages
## /
- Home (15 March 2026)
- About (10 March 2026)
## articles
- My First Post (12 March 2026)
- Another Article (8 March 2026)
## articles/deep
- Nested Post (5 March 2026)
This page is linked from the site navigation.
The templating.py module is the single point where rendered article HTML meets Jinja2 templates. This contract must be respected by any future template changes to avoid breaking content rendering.
The flow:
renderer.pyconverts markdown to an HTML string (article.body_html). This is a fragment — it contains no<html>,<head>, or<body>tags. It is the inner content only (e.g.<p>,<h2>,<pre>, etc.).templating.pypasses theArticleobject (includingbody_html) to the Jinja2 template as a context variable.- The template outputs the HTML fragment using the
| safefilter to prevent double-escaping.
Template contract — article.html:
{% extends "base.html" %}
{% block content %}
<article>
<header class="article-meta">
<h1>{{ article.title }}</h1>
<time datetime="{{ article.date.isoformat() }}">{{ article.date.strftime('%d %B %Y') }}</time>
{% if article.tags %}
<div class="tags">
{% for tag in article.tags %}
<a href="/tags/{{ tag }}/" class="tag">{{ tag }}</a>
{% endfor %}
</div>
{% endif %}
{% if article.description %}
<p class="description">{{ article.description }}</p>
{% endif %}
</header>
<div class="article-body">
{{ article.body_html | safe }}
</div>
</article>
{% endblock %}Critical rules for template authors:
article.body_htmlmust always be output with| safe. It is pre-rendered HTML from markdown-it-py. Without| safe, all tags will be escaped and rendered as visible text.- The
<div class="article-body">wrapper is the styling hook. CSS targeting.article-body h2,.article-body p,.article-body preetc. controls how article content looks. Do not remove this wrapper. - The metadata block (
<header class="article-meta">) is separate from the body and should remain above it. Metadata comes from theArticleobject fields, not frombody_html. base.htmlmust define a{% block content %}{% endblock %}that child templates fill.
Template contract — base.html (minimal structure):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ site.title }}{% endblock %}</title>
{% if article is defined and article.description %}
<meta name="description" content="{{ article.description }}">
{% endif %}
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="site-nav">
{% block nav %}{% include "_nav.html" %}{% endblock %}
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<p>{{ site.title }} — built with <a href="https://github.com/phalt/paulblish">Paulblish</a></p>
</footer>
</body>
</html>This structure ensures that future template changes (new nav items, footer content, meta tags) can happen without touching the content injection path.
article.html receives:
{
"article": Article, # the full article object
"site": SiteConfig, # site-level config (title, base_url, etc.)
}listing.html receives:
{
"title": str, # page title ("All Articles" or "Tagged: python")
"articles": list[Article], # sorted by date descending
"site": SiteConfig,
}all_pages.html receives:
{
"groups": dict[str, list[Article]], # path_prefix -> articles, ordered
"site": SiteConfig,
}Site configuration is loaded from one of two sources, tried in order:
site.toml— a TOML file in the root of the source directory (preferred).Home.mdfrontmatter — YAML frontmatter fields in theHome.mdfile at the source root (fallback, useful for Obsidian where.tomlfiles are inconvenient).
Method 1: site.toml
[site]
title = "My Blog"
base_url = "https://username.github.io/blog"
description = "A blog about things."
author = "Paul"
cname = "" # optional — custom domain, e.g. "blog.example.com"
avatar = "" # optional — path to a square image for the home pageMethod 2: Home.md frontmatter
---
publish: true
title: "My Blog"
base_url: "https://username.github.io/blog"
description: "A blog about things."
author: "Paul"
cname: "" # optional
avatar: "" # optional
---The same required and optional fields apply regardless of source. If site.toml is present it takes priority and Home.md frontmatter is ignored for configuration purposes.
Required fields (in either source): title, base_url, description, author.
Optional fields: cname (default ""), avatar (default "").
CNAME support: If the cname field is set to a non-empty string, the build writes a CNAME file to the output root containing that value. This is required for GitHub Pages to serve the site on a custom domain. Example:
cname = "blog.paulblish.dev"Produces _site/CNAME containing blog.paulblish.dev. If cname is empty or absent, no CNAME file is generated.
Avatar support: If the avatar field is set, it should be a path (relative to the source directory) to a square image. The image is copied to output/assets/ and rendered on the home page (see §6.9).
If neither site.toml nor Home.md with the required fields is found, pb build exits with an error (exit code 1):
Error: No site configuration found in /path/to/source
Either create a site.toml file or add site config fields to your Home.md frontmatter:
title, base_url, description, author
See: https://github.com/phalt/paulblish#site-configuration
CLI flags (--base-url) override values from whichever source was used.
The base.html template includes a top-level navigation bar rendered on every page. The nav bar contains a fixed set of links:
| Link | Target | Notes |
|---|---|---|
| Site title | / |
The site title from site.toml, links to the home page |
| All Pages | /all/ |
Links to the all-pages listing |
The nav bar is minimal by design. It is rendered from a partial template (_nav.html or inline in base.html) so it can be customised by template authors without touching article rendering. The nav bar should visually use the monospace heading font and muted teal accent for the active/hover state.
Future additions (tag index, RSS icon) can be added to the nav bar, but v1 ships with only the two links above.
When Home.md is rendered as the site index page, the template includes two additional elements above the Home.md body content:
1. ASCII Art Banner
A pre-formatted ASCII art rendering of the word "Hello" is displayed at the top of the home page inside a <pre class="ascii-banner"> block. This is hardcoded in the home page template (not in the markdown file). Example:
_ _ _ _
| | | | ___| | | ___
| |_| |/ _ \ | |/ _ \
| _ | __/ | | (_) |
|_| |_|\___|_|_|\___/
The ASCII art uses the monospace heading font and muted teal colour (#5e9e91). It is purely decorative and should be wrapped in an aria-hidden="true" attribute for accessibility.
2. Author Avatar
If site.avatar is configured in site.toml, a square image is rendered below the ASCII banner and above the page content:
<div class="home-avatar">
<img src="/assets/avatar.png" alt="{{ site.author }}" class="avatar" />
</div>The avatar is displayed as a square image (e.g. 120×120px), styled with a 1px border in the muted teal accent colour. No border-radius — sharp corners, consistent with the brutalist aesthetic.
If site.avatar is not configured, the avatar section is simply omitted — no placeholder, no broken image.
The home page template structure is:
┌─────────────────────────┐
│ ASCII "Hello" art │
├─────────────────────────┤
│ Avatar (if set) │
├─────────────────────────┤
│ Home.md body_html │
└─────────────────────────┘
- During rendering, collect all image/file references from published articles (
![[file]]and standardsyntax). - Resolve each reference against the source directory (Obsidian uses flat or shortest-path matching by default).
- Copy only referenced assets to
output/assets/. - Rewrite all references in the rendered HTML to point to
/assets/{filename}. - If an asset is referenced but not found, log a warning and render a placeholder/broken-image indicator.
Collision handling: if two different directories contain assets with the same filename, disambiguate with a content hash prefix (e.g. a3f8_photo.png).
The build generates an RSS 2.0 feed at output/feed.xml, addressable at /feed.xml on the deployed site. The feed allows readers to subscribe to new articles via any feed reader.
Feed metadata (from site.toml):
| Field | Source | Example |
|---|---|---|
<title> |
site.title |
My Blog |
<link> |
site.base_url |
https://example.com |
<description> |
site.description |
A blog about things. |
<managingEditor> |
site.author |
Paul |
<lastBuildDate> |
Build time | Tue, 15 Mar 2026 00:00:00 +0000 |
Feed items:
Each published article (excluding Home.md) becomes an <item> in the feed, sorted by date descending (newest first). Maximum 20 items in the feed to keep it lightweight.
| Field | Source |
|---|---|
<title> |
article.title |
<link> |
site.base_url + article.url_path |
<guid> |
site.base_url + article.url_path (permalink) |
<pubDate> |
article.date in RFC 822 format |
<description> |
article.description if present, otherwise first 280 characters of body_markdown stripped of markup |
Implementation details:
- The feed is generated using Python's
xml.etree.ElementTree(stdlib) — no extra dependency needed. - The feed URL should be discoverable via a
<link rel="alternate" type="application/rss+xml">tag inbase.html's<head>. - If
site.base_urlis/or empty, feed item links use relative paths. Otherwise, full absolute URLs are constructed.
pb — registered as a console script via pyproject.toml:
[project.scripts]
pb = "paulblish.cli:main"pb build --source ~/obsidian/blog --output ./_site
pb build --source ~/obsidian/blog --output ./_site --base-url https://example.com
pb clean --output ./_site
| Flag | Default | Description |
|---|---|---|
--source, -s |
. (cwd) |
Path to the Obsidian vault directory to scan. Must contain a site.toml. |
--output, -o |
./_site |
Path to write generated HTML |
--base-url |
/ |
Base URL for absolute links and RSS feed (overrides site.toml) |
--templates |
bundled defaults | Path to custom Jinja2 templates directory |
--clean |
false |
Delete output directory before building |
--drafts |
false |
Include articles with publish: false (for local preview) |
--verbose, -v |
false |
Verbose logging |
| Flag | Default | Description |
|---|---|---|
--output, -o |
./_site |
Path to delete |
Before scanning any files, pb build performs the following checks. Failure at any step exits with code 1.
- Source directory exists — if
--sourcedoes not point to an existing directory, exit with error. - Config source found — if neither
{source}/site.tomlnor{source}/Home.md(case-insensitive) exists, exit with a clear error listing the required fields and linking to documentation. - Config is valid — if the config source exists but cannot be parsed, or is missing required fields (
title,base_url,description,author), exit with an error naming the source file and the missing field.
The build command must produce clear, structured output showing exactly what it is doing. This is not optional — the output is a core part of the tool's UX.
Standard output (non-verbose):
Paulblish v0.1.0
Source: ~/obsidian/blog
Config: site.toml ✓
Scanning...
✓ articles/my-first-post.md → /articles/my-first-post/
✓ articles/deep/bar.md → /articles/deep/bar/
✓ Home.md → / (index)
✗ articles/draft-idea.md (no publish: true)
✗ notes/scratch.md (no frontmatter)
✗ random.txt (not markdown)
Building 3 articles, skipped 3 files
→ _site/articles/my-first-post/index.html
→ _site/articles/deep/bar/index.html
→ _site/index.html
→ _site/all/index.html
Done. 3 articles, 1 asset, 0 warnings.
Error output (missing site.toml):
Paulblish v0.1.0
Source: ~/obsidian/blog
Error: No site.toml found in ~/obsidian/blog
Every source directory must contain a site.toml file.
See: https://github.com/phalt/paulblish#site-configuration
Key requirements:
- Every
.mdfile found is reported as either picked up (✓) or skipped (✗ with reason). - Non-markdown files in the directory are listed as skipped if
--verboseis set; otherwise they are counted but not individually listed. - The reason for skipping is always shown (no frontmatter,
publishnot true, not a.mdfile). - Output paths are shown for every file written.
- A summary line reports totals.
Verbose mode (-v) additionally shows: asset copying, template rendering, wikilink resolution details, and timing information.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Build error (missing source dir, missing site.toml, invalid config, template error) |
2 |
Warnings only (dead wikilinks, missing assets) — build still succeeds |
The intended workflow is:
- Build locally: Run
pb build --source ~/obsidian/blog --output ./_siteon your own machine. The source directory (your Obsidian vault) lives wherever you keep it — it is not part of the Paulblish repository. - Commit the output: The
_site/directory is committed to the repository. It contains the fully rendered static site. - Push to deploy: Pushing to
maintriggers a GitHub Actions workflow that deploys the contents of_site/to GitHub Pages. The workflow does not install Python,uv, or runpb— it only deploys pre-built files.
This keeps the CI pipeline trivial and fast (no build step), and means the source Obsidian vault never needs to be committed to the repo.
paulblish/ # the repo root
├── .github/
│ └── workflows/
│ ├── deploy.yml # deploys _site/ to GitHub Pages
│ └── test.yml # runs pytest + ruff on PRs
├── .gitignore # .venv/, __pycache__/, etc. — NOT _site/
├── .python-version
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── pyproject.toml
├── uv.lock
├── paulblish/ # the package (flat layout, like clientele)
│ ├── __init__.py
│ ├── cli.py # click entry point
│ ├── config.py # SiteConfig loading + site.toml validation
│ ├── models.py # Article dataclass, SiteConfig
│ ├── scanner.py # directory walk, frontmatter parse, filtering
│ ├── renderer.py # markdown-it-py setup + plugin chain
│ ├── plugins/ # markdown-it-py plugins for Obsidian syntax
│ │ ├── __init__.py
│ │ ├── wikilinks.py
│ │ ├── callouts.py
│ │ ├── highlights.py
│ │ └── embeds.py
│ ├── linker.py # path lookup table, wikilink resolution
│ ├── assets.py # asset discovery, copy, path rewriting
│ ├── templating.py # Jinja2 environment setup + render
│ └── writer.py # output directory creation + file writing
├── templates/ # default Jinja2 templates, bundled with the package
│ ├── base.html
│ ├── _nav.html
│ ├── article.html
│ ├── home.html
│ ├── listing.html
│ ├── all_pages.html
│ └── static/
│ └── style.css
├── tests/
│ ├── conftest.py
│ ├── test_scanner.py
│ ├── test_renderer.py
│ ├── test_linker.py
│ ├── test_assets.py
│ └── fixtures/ # sample .md files for testing
│ ├── simple_article.md
│ ├── article_with_wikilinks.md
│ ├── Home.md
│ ├── site.toml # test fixture config
│ └── assets/
│ └── test_image.png
└── _site/ # ← generated locally, committed to repo, deployed by CI
├── index.html
├── all/
│ └── index.html
├── articles/
│ └── ...
├── assets/
│ └── ...
└── static/
└── style.css
Note: _site/ is not in .gitignore — it is committed.
.venv/
__pycache__/
*.egg-info/
dist/
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "paulblish"
version = "0.1.0"
description = "A cyberpunk static site generator for Obsidian vaults."
readme = "README.md"
license = "MIT"
requires-python = ">=3.13"
authors = [
{ name = "Paul" },
]
dependencies = [
"python-frontmatter>=1.1",
"markdown-it-py>=3.0",
"jinja2>=3.1",
"pygments>=2.18",
"click>=8.1",
]
[project.scripts]
pb = "paulblish.cli:main"
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.9",
]
[tool.ruff]
line-length = 120
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pytest.ini_options]
testpaths = ["tests"]Following the clientele convention:
.PHONY: install test lint format clean
install:
uv sync
test:
uv run pytest
lint:
uv run ruff check .
format:
uv run ruff format .
clean:
rm -rf _site/
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type d -name "*.egg-info" -exec rm -rf {} +Note: there is no make build target. The pb build command requires a --source argument pointing to your Obsidian vault, which lives outside this repo. Run it directly:
uv run pb build --source ~/obsidian/blog --output ./_site
This runs on push to main and deploys the pre-built _site/ directory to GitHub Pages. It does not run pb build — the site is built locally and committed.
name: Deploy Site
on:
push:
branches: [main]
paths:
- '_site/**'
permissions:
pages: write
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/upload-pages-artifact@v3
with:
path: ./_site
- id: deployment
uses: actions/deploy-pages@v4This is intentionally minimal. No Python, no uv, no build step. Push pre-built HTML → deploy.
Runs on PRs — linting and tests for the pb tool itself.
name: Tests
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
with:
version: "latest"
- run: uv sync
- run: uv run ruff check .
- run: uv run pytest1. Validate source (source dir exists?)
2. Validate config (site.toml exists and is valid?)
3. Load config (parse site.toml + merge CLI flag overrides)
4. Scan (walk source dir, parse frontmatter, filter publishable)
5. Report scan (print ✓/✗ for every file found)
6. Build path map (note name -> url_path for all published articles)
7. Render (markdown -> HTML for each article, resolving wikilinks via path map)
8. Collect assets (find all referenced images/files, including avatar if configured)
9. Copy assets (to output/assets/, rewrite paths in HTML)
10. Template (wrap rendered HTML in Jinja2 templates, including metadata block)
11. Generate listings (all-pages grouped by path, tag pages)
12. Generate CNAME (write CNAME file if site.cname is configured)
13. Write (create output directory structure, write all files)
14. Report (log stats: N articles, N assets, N warnings)
The README must include the following sections:
-
Header — Project name, one-line description, badges (Python version, license).
-
What is this? — Brief explanation: a CLI tool that converts an Obsidian vault into a static HTML site with a cyberpunk aesthetic. The source vault lives on your machine; the generated output is committed to the repo and deployed via GitHub Pages.
-
Quick Start — The full workflow from zero to deployed site:
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 — How to install for development:
git clone https://github.com/phalt/paulblish.git cd paulblish make install -
Usage — Full CLI documentation for
pb buildandpb clean, with all flags documented. -
Site Configuration — Document the
site.tomlformat with all fields (title,base_url,description,author,cname,avatar) and an example. Explain that this file is required and what error you'll see if it's missing. Document custom domain setup viacname. -
Frontmatter Schema — Document the full frontmatter contract (
publish,title,slug,date,tags,description) with examples. -
Directory Structure — Explain how source directory paths map to output URLs.
-
The Home File — Explain the
Home.mdconvention, the ASCII banner, and the avatar feature. -
Deployment — Explain the workflow: build locally → commit
_site/→ push → GitHub Actions deploys. Document the GitHub Pages repo settings needed (deploy from Actions). Document custom domain setup viacnameinsite.toml. -
Development — How to run tests, lint, format (
make test,make lint,make format). -
Fork Your Own Copy (see §14.2).
A dedicated section explaining how someone can copy and run their own instance of Paulblish:
## Fork Your Own Copy
Paulblish is designed so anyone can fork it and run their own blog. To set up your own:
1. Fork this repository (or use "Use this template" on GitHub).
2. Clone it locally and run `make install`.
3. Create a `site.toml` in the root of your Obsidian content directory:
```toml
[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-
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.
---
## 15. Output Structure
_site/ ├── CNAME # custom domain (only if site.cname is set) ├── index.html # Home.md content or article listing fallback ├── all/ │ └── index.html # all-pages listing, grouped by path ├── articles/ │ ├── my-first-post/ │ │ └── index.html # /articles/my-first-post/ │ └── deep/ │ └── bar/ │ └── index.html # /articles/deep/bar/ ├── about/ │ └── index.html # /about/ (root-level page) ├── assets/ │ ├── photo.png │ └── diagram.svg ├── static/ │ └── style.css ├── tags/ │ ├── python/ │ │ └── index.html # articles tagged "python" (P1) │ └── tooling/ │ └── index.html └── feed.xml # RSS/Atom feed (P1)
---
## 16. Implementation Plan — TODO
The work is ordered so that each step produces a testable, runnable increment.
**Rule: Every implementation step must include test coverage.** Each step either confirms existing tests cover the change and adapts them if needed, or writes new tests before the step is marked as done. Step 1.8 covers any remaining integration-level tests, but unit tests ship with each step.
### Phase 1: Scanner + Renderer (core pipeline)
This is the minimum viable tool — point it at a directory, get HTML files out.
- [x] **1.1** Scaffold the project following clientele conventions: `pyproject.toml` (hatchling backend), flat `paulblish/` package, `Makefile`, `.python-version`, `uv.lock`, `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `LICENSE`, `.gitignore` (without `_site/`).
- [x] **1.2** Implement `models.py`: `Article` and `SiteConfig` dataclasses with full type hints. `Article` must include `relative_path`, `path_prefix`, and `url_path` fields for directory-preserving paths.
- [x] **1.3** Implement `config.py`: load and validate `site.toml` from the source directory. Exit with a clear error (code 1) if the file is missing or malformed. CLI flags override individual values.
- [x] **1.4** Implement `scanner.py`: recursive directory walk, frontmatter parsing with `python-frontmatter`, filtering on `publish: true`, title/slug/date resolution logic, `Home.md` detection. Must build `path_prefix` from the file's directory relative to the source root.
- [x] **1.5** Implement `renderer.py`: base `markdown-it-py` setup with Pygments code highlighting. Standard markdown only — no Obsidian plugins yet.
- [x] **1.6** Implement `writer.py`: create output dirs preserving the directory structure (e.g. `_site/articles/foo/index.html`), write rendered HTML with a minimal HTML wrapper (no Jinja2 templates yet).
- [x] **1.7** Implement `cli.py`: wire up `pb build` command with `--source` and `--output` using `click`. Must validate source dir and `site.toml` before scanning. Must print ✓/✗ output for every file scanned, showing picked-up files with their output path and skipped files with the skip reason.
- [x] **1.8** Write tests for: config loading/validation (missing file, invalid TOML, missing `[site]` table), scanner (frontmatter parsing, filtering, slug derivation, path prefix construction, Home detection), and renderer (basic markdown → HTML). Use `tests/fixtures/` with sample `.md` files and a fixture `site.toml`.
- [x] **1.9** Manual test: run against a real Obsidian directory, verify output.
**Milestone:** `uv run pb build -s ~/obsidian/blog -o ./_site` validates `site.toml`, produces HTML files from markdown with directory-preserving paths, and the CLI output clearly shows what was picked up and what was skipped.
### Phase 2: Wikilinks + Linking
- [x] **2.1** Implement `linker.py`: build the path lookup table from scanned articles (note name → `url_path`).
- [x] **2.2** Implement `plugins/wikilinks.py`: `markdown-it-py` plugin to parse `[[wikilink]]` and `[[wikilink|alias]]` syntax, resolve via path map, handle dead links.
- [x] **2.3** Write tests for wikilink resolution (found, not found, aliased, self-referencing, cross-directory links).
**Milestone:** Internal links between published articles work with correct directory-aware paths. Dead links render as styled plain text.
### Phase 3: Templates + Cyberpunk Theme
- [x] **3.1** Create default templates: `base.html` (with `{% block content %}`), `_nav.html` (site title + All Pages link), `article.html`, `home.html`, `listing.html`, `all_pages.html`. Implement the cyberpunk / brutalist theme in `style.css` using the muted CP2077-inspired palette.
- [x] **3.2** Implement the content injection contract in `article.html`: `{{ article.body_html | safe }}` inside `<div class="article-body">`. Document the `| safe` requirement clearly in code comments.
- [x] **3.3** Implement the article metadata block in `article.html`: title as `<h1>`, date, tags (as styled pills linking to tag pages), description as lead paragraph.
- [x] **3.4** Implement `home.html`: ASCII art "Hello" banner (`<pre class="ascii-banner" aria-hidden="true">`), optional avatar image (from `site.avatar`), then `Home.md` body content via `| safe`.
- [x] **3.5** Implement `_nav.html`: site title linking to `/`, "All Pages" linking to `/all/`.
- [x] **3.6** Implement `all_pages.html`: generate the all-pages listing grouped by `path_prefix`, with path headings.
- [x] **3.7** Implement `templating.py`: Jinja2 environment, render article pages (using `article.html`), render home page (using `home.html`), render all-pages page, render tag listing pages.
- [x] **3.8** Update `writer.py` to use templated output. Generate `CNAME` file if `site.cname` is configured.
- [x] **3.9** Add `--templates` and `--base-url` CLI flags.
**Milestone:** Site has the cyberpunk aesthetic, article metadata is visible on every post, the home page shows ASCII art + avatar + content, the nav bar works, the all-pages listing works, and CNAME is generated for custom domains.
### Phase 4: Asset Handling
- [x] **4.1** Implement `assets.py`: scan rendered HTML for image/file references, resolve against source directory.
- [x] **4.2** Implement asset copying with collision-safe naming.
- [x] **4.3** Implement `plugins/embeds.py`: `![[image.png]]` syntax → `<img>` tag with rewritten path.
- [x] **4.4** Write tests for asset discovery, copying, and path rewriting.
**Milestone:** Images and files render correctly in the output.
### Phase 5: Remaining Obsidian Syntax
- [x] **5.1** Implement `plugins/callouts.py`: callout block parsing and HTML rendering.
- [x] **5.2** Implement `plugins/highlights.py`: `==text==` → `<mark>`.
- [x] **5.3** Add footnote support (may be available as an existing `markdown-it-py` plugin).
- [x] **5.4** Add Mermaid support (pass-through to `<pre class="mermaid">` + include mermaid.js in base template).
**Milestone:** Most real-world Obsidian articles render correctly.
### Phase 6: Polish + Deploy
- [x] **6.1** Implement `pb clean` command.
- [x] **6.2** Add `--drafts` flag support.
- [x] **6.3** Generate tag index pages (`/tags/{tag}/index.html`).
- [x] **6.4** Finalise CLI output: build stats, timing, warning summary.
- [x] **6.5** Write both GitHub Actions workflows (`deploy.yml` and `test.yml`).
- [x] **6.6** Write `README.md` with all required sections (see §14), including the "Fork Your Own Copy" guide.
- [x] **6.7** Write `CHANGELOG.md` and `CONTRIBUTING.md`.
**Milestone:** Production-ready. Build locally, commit, push, site deploys automatically. Anyone can fork the repo and have their own blog running in minutes.
### Phase 7: RSS Feed
- [x] **7.1** Implement `feed.py`: generate RSS 2.0 XML from published articles (excluding Home), sorted by date descending, max 20 items. Use `xml.etree.ElementTree` (stdlib). Feed metadata from `SiteConfig`.
- [x] **7.2** Update `writer.py` to call feed generation and write `feed.xml` to the output root.
- [x] **7.3** Update `base.html` to include `<link rel="alternate" type="application/rss+xml">` in `<head>` for feed discovery.
- [x] **7.4** Write tests for: feed XML structure, item count limit, date formatting (RFC 822), Home exclusion, description fallback, feed discovery link in HTML output.
**Milestone:** `/feed.xml` is generated on every build, discoverable via `<link>` tag, and contains the 20 most recent articles with correct metadata.
---
### Phase 8: Extra Features
A collection of high-value improvements and two larger features (light/dark mode toggle and incremental builds) that round out the project from a working tool to a polished, production-quality blog platform.
**Rule: Every implementation step must include test coverage.** Each step either confirms existing tests cover the change and adapts them if needed, or writes new tests before the step is marked done. Documentation must also be updated when each feature is implemented — see the detail sections below.
- [x] **8.1** Prev / Next article navigation links on every article page.
- [x] **8.2** Reading time estimate displayed in article header.
- [x] **8.3** Open Graph and Twitter Card meta tags on every page.
- [X] **8.4** Generate `sitemap.xml` on every build.
- [x] **8.5** Generate `robots.txt` on every build.
- [x] **8.6** Generate a styled `404.html` on every build.
- [X] **8.7** Light / dark mode toggle with `localStorage` persistence and anti-FOUC inline script.
- [x] **8.8** Incremental builds via `--incremental` flag and `.pb-manifest.json`.
- [x] **8.9** Social icons in nav and footer for Bluesky, GitHub, and email.
**Milestone:** All pages include social sharing meta tags, are discoverable by crawlers, have a working 404, support light/dark theming, large vaults can be rebuilt incrementally in a fraction of the full build time, and the site displays the author's social presence via icons in the nav and footer.
---
#### 8.1 — Prev / Next Article Navigation
**What:** Every article page gets navigation links to the chronologically adjacent articles (previous = older, next = newer). The Home article is excluded from the sequence.
**Requirements:**
- Articles are ordered by `date` ascending to form the sequence. Home article is never included.
- The `Article` dataclass gains two optional fields: `prev_article: Article | None = None` and `next_article: Article | None = None`.
- `writer.py` (or a dedicated step in the build pipeline) sets these fields on each article after scanning and sorting, before rendering.
- `article.html` renders a `<nav class="article-nav">` block **below** the `.article-body` div containing:
- A "← Older" link on the left if `article.prev_article` exists, showing the previous article's title.
- A "Newer →" link on the right if `article.next_article` exists, showing the next article's title.
- If only one side exists, the other side renders as an empty placeholder (preserves layout).
- Articles with identical dates retain stable ordering (sort by `url_path` as tiebreaker).
- No new Python dependencies.
**Tests:** prev/next fields set correctly on a sequence of 3+ articles; first article has no `prev`; last article has no `next`; single-article list has neither; Home excluded from sequence; template renders links with correct titles and URLs.
**Documentation:** Update `README.md` — mention prev/next navigation in the "Templates" / article rendering section. Update `CHANGELOG.md`.
---
#### 8.2 — Reading Time Estimate
**What:** Every article displays an estimated reading time ("5 min read") alongside the date in the article header.
**Requirements:**
- The `Article` dataclass gains a `reading_time_minutes: int` field, defaulting to `0`.
- Calculation: count words in `body_markdown` (split on whitespace), divide by 200 (words per minute), round up to nearest whole minute. Minimum value is 1.
- The field is populated in `scanner.py` (or `writer.py` — after rendering) before the article is passed to the template. Since `body_markdown` is available at scan time, populate it in `scanner.py`.
- `article.html` renders the reading time in the `<header class="article-meta">` block, adjacent to the `<time>` element: `<span class="reading-time">{{ article.reading_time_minutes }} min read</span>`.
- Home article also has the field populated but the `home.html` template does not display it (it is not an article in the traditional sense).
- No new Python dependencies.
**Tests:** word count produces correct minute values; minimum 1 minute; round-up behaviour; populated in Article after scan; template renders the span; home template does not render it.
**Documentation:** Update `README.md` frontmatter schema section — note this is auto-calculated. Update `CHANGELOG.md`.
---
#### 8.3 — Open Graph + Twitter Card Meta Tags
**What:** Every page renders a full set of Open Graph and Twitter Card `<meta>` tags so shared links look correct on social media platforms (title, description, image, URL).
**Requirements:**
- Tags are added to `base.html` inside `<head>`, after the existing `<meta name="description">` block.
- The following tags are always rendered (using site-level defaults as fallbacks):
```html
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:site_name" content="{{ site.title }}">
<meta property="og:title" content="{{ page_title }}">
<meta property="og:description" content="{{ page_description }}">
<meta property="og:url" content="{{ page_url }}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ page_title }}">
<meta name="twitter:description" content="{{ page_description }}">
- Template context variables used:
page_title:article.titleif an article is in context, otherwisesite.title.page_description:article.descriptionif set and non-empty, otherwisesite.description.page_url:site.base_url + article.url_pathif an article is in context, otherwisesite.base_url.
- These variables must be passed from every
render_*function intemplating.py. All three render functions (render_article,render_all_pages,render_tag_page) must inject them. og:typeis always"article"for all pages (this is standard practice for blog content).- The
og:image/twitter:imagetag is only rendered ifsite.avataris configured. When present:<meta property="og:image" content="{{ site.base_url }}/assets/{{ site.avatar | basename }}">. - No new Python dependencies.
Tests: OG tags present in article HTML; OG tags use article title/description when available; OG tags fall back to site title/description on listing pages; page URL correct per page type; og:image absent when no avatar; og:image present and correct when avatar configured.
Documentation: Update README.md Site Configuration section — document that avatar also doubles as the Open Graph image. Update CHANGELOG.md.
What: A standard sitemap.xml is generated at _site/sitemap.xml on every build, listing all published article URLs. This helps search engine crawlers discover and index all pages.
Requirements:
- New function
generate_sitemap(articles, site)in a new modulepaulblish/sitemap.py. Uses stdlibxml.etree.ElementTree(same pattern asfeed.py). - Includes all published articles (including Home), tag pages, and the all-pages listing (
/all/). Does not includerobots.txt,feed.xml,CNAME, or static assets. - Each
<url>entry contains:<loc>: the full absolute URL (site.base_url + url_path).<lastmod>: the article'sdateinYYYY-MM-DDformat (ISO 8601 date only). For listing pages (/all/,/tags/{tag}/), use the date of the most recently modified article in that group. If no articles, omit<lastmod>.
- XML namespace:
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9". writer.pycalls sitemap generation and writes the file, adding the path to the returned written list (so it appears in CLI output).robots.txt(8.5) will reference this file — coordinate the URL format.
Tests: sitemap contains correct <loc> for all articles; Home maps to base_url/; <lastmod> format is YYYY-MM-DD; tag pages and /all/ included; empty article list produces valid (but empty) sitemap; XML namespace correct; file written to output root.
Documentation: Update README.md Deployment section — mention sitemap is auto-generated. Update CHANGELOG.md.
What: A standard robots.txt is written to _site/robots.txt on every build. It allows all crawlers and points them to sitemap.xml.
Requirements:
-
Content is always the same template, with
site.base_urlinterpolated for theSitemap:line:User-agent: * Allow: / Sitemap: {site.base_url}/sitemap.xml -
Written by a new function
write_robots(output_dir, site)inwriter.py(no separate module needed — it is trivial). -
Called from
write()and its path added to the returned written list. -
No new Python dependencies.
Tests: file is written; content contains User-agent: *; Sitemap: line contains the correct absolute URL.
Documentation: Update README.md Deployment section alongside the sitemap note. Update CHANGELOG.md.
What: A styled _site/404.html is generated on every build. GitHub Pages automatically serves this file for any URL that doesn't match a file in _site/.
Requirements:
- New template
templates/404.htmlextendingbase.html. Content:- An
<h1>reading "404 — Not Found". - A short paragraph: "The page you're looking for doesn't exist."
- A link back to the site home (
site.base_url): "← Go home". - The page uses the same cyberpunk aesthetic as the rest of the site (inherits from
base.html).
- An
- New function
render_404(site, templates_dir)intemplating.py. writer.pycalls this and writesoutput_dir / "404.html", adding it to the returned written list.- No new Python dependencies.
Tests: 404.html is written to the output root; content contains "404"; contains a link to site.base_url; valid HTML document.
Documentation: Update README.md Deployment section — mention GitHub Pages automatically serves 404.html. Update CHANGELOG.md.
What: The site gains a persistent light/dark mode toggle button in the nav bar. The default is dark (current cyberpunk theme). The user's preference is remembered across page loads using localStorage.
Requirements:
- A CSS light theme is defined in
style.cssusing adata-theme="light"attribute on<html>. The light palette should invert the key colours: light background (near-white, e.g.#f5f5f0), dark text (e.g.#1a1a1f), with the same teal/amber accent colours retained. - The default theme is dark.
<html>has nodata-themeattribute by default (dark styles are the base). _nav.htmlrenders a toggle button:<button id="theme-toggle" aria-label="Toggle light/dark mode">◑</button>(or similar accessible symbol).- A small inline
<script>block inbase.html(before</body>) handles:- On page load: read
localStorage.getItem('theme')and applydata-themeto<html>if set. - On button click: toggle
data-themebetween""(dark) and"light", persist tolocalStorage.
- On page load: read
- The script must be minimal vanilla JS — no framework, no external CDN. Keep it under 20 lines.
- The script must run before first paint to avoid flash of wrong theme (FOUC). Place it as an inline
<script>in<head>(not deferred). - No new Python dependencies.
Tests: _nav.html contains a theme toggle button; base.html contains the anti-FOUC inline script in <head>; style.css contains [data-theme="light"] styles. (Full JS behaviour is not unit-testable in Python — note this explicitly in the test file.)
Documentation: Update README.md — add a "Theming" section explaining the dark default and light mode toggle. Note that the light theme CSS variables can be customised in style.css. Update CHANGELOG.md.
What: A --incremental flag for pb build that skips re-rendering articles whose source file has not been modified since the last build. Reduces build time significantly for large vaults.
Requirements:
-
A build manifest file is written to
output_dir / ".pb-manifest.json"at the end of every full or incremental build. It contains a JSON object mapping each source file's path (relative tosource_dir) to itsmtime(float, seconds since epoch) at the time it was last built:{ "articles/foo.md": 1743000000.0, "Home.md": 1743000001.5 } -
New module
paulblish/manifest.pywith:load_manifest(output_dir) -> dict[str, float]— reads.pb-manifest.json; returns{}if not found or invalid JSON.save_manifest(output_dir, articles)— writes the manifest from the current article list's source file mtimes.
-
When
--incrementalis passed:- Load the manifest from the output dir.
- After scanning, split articles into three groups:
- stale: source file mtime > manifest value (or not in manifest) — must be re-rendered.
- fresh: source file mtime ≤ manifest value — skip rendering and writing; their existing output files are left untouched.
- absent: source file no longer available, but file path is in manifest; these need to be deleted.
- Only stale articles are rendered, templated, and written.
- Asset collection and copying still runs over all published articles (not just stale ones), so that asset references from fresh articles are not broken.
- The all-pages listing, tag pages, RSS feed, sitemap, and robots.txt are always regenerated (they reflect the full article set).
- CNAME and 404 are always written (cheap operations).
- REMOVE UNPUBLISHED FILES: source files that are no longer present in the directory should have their static page removed, and the manifest updated to remove them as well.
- After the build, save the updated manifest reflecting the current mtimes of all published articles.
-
CLI output: stale articles print
→ rebuilt; fresh articles print(unchanged)in place of the output path. -
--incrementaland--draftsare compatible: draft articles are included in the manifest when--draftsis active. -
No new Python dependencies.
Tests: manifest load/save round-trip; fresh articles are not written when --incremental; stale articles (mtime changed) are re-rendered; manifest is updated after build; --incremental without existing manifest behaves as a full build; listing pages always regenerated; CLI output correctly distinguishes rebuilt vs unchanged.
Documentation: Update README.md Usage section — document --incremental flag under pb build. Add a note on when to use it (large vaults). Update CHANGELOG.md.
What: If any social contact fields are configured (bluesky, github, email), a row of icon links is rendered in both the site nav bar (right-aligned) and the site footer. Icons are inline SVGs — no external CDN, no JavaScript, no new dependencies.
Requirements:
Three new optional fields added to SiteConfig (all default to ""):
bluesky— a Bluesky profile URI, e.g."https://bsky.app/profile/paulblish.bsky.social". Any non-empty string is treated as the link href.github— a GitHub profile URI, e.g."https://github.com/phalt". Any non-empty string is treated as the link href.email— an email address, e.g."paul@example.com". Rendered as amailto:link.
These are read from site.toml under [site] and from Home.md frontmatter via the existing config.py get/fallback mechanism.
-
A new partial template
_social.htmlrenders the icon row. It is included in both_nav.htmlandbase.html(footer). Because the same partial appears in two layout positions it must be self-contained — no assumptions about surrounding markup. -
The partial only renders if at least one of the three fields is non-empty. If all three are empty, it renders nothing (no empty
<div>). -
Output structure:
<div class="social-icons"> <!-- rendered only if site.github is set --> <a href="{{ site.github }}" class="social-icon" aria-label="GitHub" rel="noopener noreferrer" target="_blank"> <!-- GitHub SVG icon --> </a> <!-- rendered only if site.bluesky is set --> <a href="{{ site.bluesky }}" class="social-icon" aria-label="Bluesky" rel="noopener noreferrer" target="_blank"> <!-- Bluesky SVG icon --> </a> <!-- rendered only if site.email is set --> <a href="mailto:{{ site.email }}" class="social-icon" aria-label="Email"> <!-- Envelope SVG icon --> </a> </div>
All three icons are inline SVGs with width="20" height="20" viewBox="0 0 24 24" and fill="currentColor". This means they inherit their colour from CSS (color) and integrate naturally with the theme palette. Using currentColor means they match link colour by default and respond to hover states with no extra CSS.
- GitHub — the standard GitHub mark (Octocat-free mark): a simplified
<path>of the GitHub logo silhouette. Use the official path data from the GitHub primer/octiconsmark-github.svg(24px viewBox). - Bluesky — the Bluesky butterfly logomark SVG path (the AT Protocol / Bluesky official icon).
- Email — a minimal envelope icon: a rectangle with a V-shaped flap, readable at 20px.
_nav.html: The social icons<div>is placed after the existing nav links, pushed to the right end of the nav bar usingmargin-left: autoon the.social-iconselement (or by addingflex: 1spacer — see CSS below).base.htmlfooter: The{% include "_social.html" %}call is added inside.site-footer, on a new line below the existing attribution paragraph.
CSS additions to style.css
/* === Social icons === */
.social-icons {
display: flex;
gap: 0.75rem;
align-items: center;
}
.site-nav .social-icons {
margin-left: auto;
}
.social-icon {
color: var(--text);
display: flex;
align-items: center;
line-height: 1;
}
.social-icon:hover {
color: var(--accent-teal);
text-decoration: none;
}
.social-icon svg {
display: block;
}
.site-footer .social-icons {
justify-content: center;
margin-top: 0.5rem;
}No new Python dependencies. The SVG path data is hardcoded directly in _social.html as inline markup. No Python code changes are required beyond the SiteConfig model and config.py field additions.
Tests:
SiteConfigacceptsbluesky,github, andemailfields; all default to"".config.pyreads each field fromsite.tomland fromHome.mdfrontmatter; absent fields default to"".- When all three are empty,
_social.htmlrenders nothing (empty string / no.social-iconsdiv). - When
site.githubis set, rendered HTML contains the GitHub icon link with correcthrefandaria-label. - When
site.blueskyis set, rendered HTML contains the Bluesky icon link. - When
site.emailis set, rendered HTML containsmailto:link. - Social icons appear in the nav HTML (
_nav.htmlpartial output). - Social icons appear in the footer HTML (
base.htmloutput). - Icon links have
rel="noopener noreferrer"andtarget="_blank"for external links (GitHub, Bluesky); email link does not havetarget="_blank".
Documentation: Update README.md Site Configuration section — add bluesky, github, email to the fields table with descriptions. Update the site.toml example block and the Home.md frontmatter example to show these fields. Update CHANGELOG.md.