A production-ready static site generator written in C17. Converts Markdown blog posts into a complete static HTML/CSS/JS site suitable for GitHub Pages deployment.
- Markdown to HTML — Full markdown support including headers, links, code blocks, images, lists, tables, and inline HTML
- Template Engine — Lightweight templating with variable interpolation, loops, and conditionals
- Themes — Pluggable theme system; switch themes without recompiling
- RSS Feed — Automatic RSS/XML feed generation
- Local Server — Built-in multi-threaded HTTP server for previewing your site
- GitHub Pages — Output is fully compatible with GitHub Pages (relative links,
.nojekyll)
makeRequires a C17-compatible compiler (GCC 8+, Clang 5+) and POSIX (Linux / macOS).
sudo make install# Create a new blog project
cblog init myblog
cd myblog
# Create a new post
cblog new "My First Post"
# Build the static site
cblog build
# Preview locally
cblog serve --port 8080| Command | Description |
|---|---|
cblog init <name> |
Initialize a new blog project |
cblog new "Title" |
Create a new markdown post |
cblog page "Title" |
Create a new static page (not in blog feed) |
cblog build |
Build the static site into public/ |
cblog serve --port <port> |
Serve public/ on a local HTTP server |
cblog theme set <name> |
Switch the active theme |
The build generates a tags.html page that lists all tags with their associated posts grouped underneath. If a post has multiple tags, it appears under each one. A link to this page is included in the navigation bar between Archive and RSS.
The tags index page can be customized by creating a tags_index.html template in your theme's templates/ directory. Available template variables:
tags— list of tag objects, each with:tag.name— tag display nametag.slug— URL-safe tag nametag.url— link to the individual tag page (tags/<slug>.html)tag.count— number of posts with this tagtag.posts— list of posts, each withpost.title,post.date,post.url
Static pages are standalone pages that use the same markdown and templates as blog posts but are excluded from the blog feed, archive, tags, and RSS. Use them for reference material, about pages, guides, etc.
cblog page "Setup Guide for Topic A"This creates pages/setup-guide-for-topic-a.md with a simplified front matter (no tags or draft fields by default):
---
title: "Setup Guide for Topic A"
date: 2026-03-07
slug: setup-guide-for-topic-a
---
Write your page content here.You can optionally add draft: true to the front matter to exclude it from the build.
Pages are rendered to public/pages/, keeping the output directory clean:
public/
├── index.html ← blog feed (no pages here)
├── archive.html ← archive (no pages here)
├── tags.html ← tags index (no pages here)
├── rss.xml ← RSS feed (no pages here)
├── pages/
│ └── setup-guide-for-topic-a.html ← your static page
├── posts/
│ └── my-blog-post.html
└── ...
In any blog post markdown, link to a static page using its slug:
For setup instructions, see the [Setup Guide](/pages/setup-guide-for-topic-a.html).Or with base_url if deploying to a subdirectory:
See the [Setup Guide](/repo-name/pages/setup-guide-for-topic-a.html).By default, pages use the post.html theme template. If you want a different layout for pages, create a page.html template in your theme's templates/ directory. It supports the same variables as post.html (except tags and tag_list which will be empty).
config.json in the project root:
{
"site_title": "My Blog",
"author": "Your Name",
"base_url": "",
"theme": "default",
"pagination_size": 10,
"output_dir": "public",
"enable_rss": true,
"enable_seo": false,
"enable_simple_analytics": false,
"site_description": ""
}| Field | Description |
|---|---|
site_title |
Name of your blog |
author |
Author name shown in posts and footer |
base_url |
URL prefix for deployment (empty for root, /repo-name for GitHub Pages project sites) |
theme |
Active theme directory name |
pagination_size |
Max posts shown on the index page |
output_dir |
Build output directory |
enable_rss |
Generate rss.xml feed |
enable_seo |
Inject meta description, Open Graph, and Twitter Card tags into every page |
enable_simple_analytics |
Inject the Simple Analytics script into every page |
site_description |
Default meta description used when a page-specific one is not available |
When enable_seo is set to true in config.json, cblog automatically injects the following into the <head> of every generated page:
- Meta description — For blog posts, this is auto-generated from the first ~160 characters of the post content (HTML stripped, whitespace normalized). For other pages (index, archive, tags), the
site_descriptionfrom config is used. - Open Graph tags —
og:title,og:description,og:type,og:url,og:site_name - Twitter Cards —
twitter:card,twitter:title,twitter:description
Edit config.json:
{
"enable_seo": true,
"site_description": "A blog about programming, systems design, and technology"
}Then rebuild:
cblog buildFor a blog post titled "Learning Rust", the <head> will include:
<meta name="description" content="Learning Rust Rust is a systems programming language focused on safety and performance...">
<meta property="og:title" content="Learning Rust">
<meta property="og:description" content="Learning Rust Rust is a systems programming language focused on safety and performance...">
<meta property="og:type" content="article">
<meta property="og:url" content="/posts/learning-rust.html">
<meta property="og:site_name" content="My Blog">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Learning Rust">
<meta name="twitter:description" content="Learning Rust Rust is a systems programming language focused on safety and performance...">If you create a custom theme, add the SEO block to your layout.html <head>:
{% if enable_seo %}
<meta name="description" content="{{ meta_description }}">
<meta property="og:title" content="{{ og_title }}">
<meta property="og:description" content="{{ og_description }}">
<meta property="og:type" content="{{ og_type }}">
<meta property="og:url" content="{{ og_url }}">
<meta property="og:site_name" content="{{ site_title }}">
<meta name="twitter:card" content="{{ twitter_card }}">
<meta name="twitter:title" content="{{ twitter_title }}">
<meta name="twitter:description" content="{{ twitter_description }}">
{% endif %}These variables are only set when enable_seo is true in config — otherwise the block is skipped cleanly.
Simple Analytics is a privacy-friendly, cookieless analytics service. When enabled, cblog injects their tracking script just before the closing </body> tag on every generated page.
Sign up at simpleanalytics.com and add your site's domain. No additional configuration is required on their end — the script loads automatically once the domain is registered.
{
"enable_simple_analytics": true
}Then rebuild:
cblog buildWhen enable_simple_analytics is true, the following script tag is added just before </body> on every page:
<script async defer src="https://scripts.simpleanalyticscdn.com/latest.js"></script>Setting enable_simple_analytics to false (the default) omits the script entirely — no requests are made to Simple Analytics and no tracking occurs.
If you create a custom theme, add the following block to your layout.html just before </body>:
{% if enable_simple_analytics %}
<script async defer src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
{% endif %}Themes live in themes/<theme_name>/ and contain:
themes/mytheme/
├── theme.json
├── templates/
│ ├── layout.html
│ ├── index.html
│ ├── post.html
│ ├── archive.html
│ └── tag.html
└── assets/
├── css/
│ └── style.css
└── js/
└── main.js
Switch themes at any time without recompiling:
cblog theme set mytheme
cblog buildmkdir -p themes/mytheme/templates
mkdir -p themes/mytheme/assets/css
mkdir -p themes/mytheme/assets/jsEvery theme needs a theme.json metadata file in its root:
{
"name": "mytheme",
"version": "1.0.0",
"author": "Your Name",
"description": "A clean minimal theme"
}You must provide 5 template files in templates/:
This is the outer HTML shell. Use {{{ content }}} (triple braces for raw/unescaped HTML) to mark where page content is injected.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }} — {{ site_title }}</title>
<link rel="stylesheet" href="{{ base_url }}/assets/css/style.css">
<link rel="alternate" type="application/rss+xml"
title="{{ site_title }}" href="{{ base_url }}/rss.xml">
</head>
<body>
<header>
<nav>
<a href="{{ base_url }}/">{{ site_title }}</a>
<a href="{{ base_url }}/">Home</a>
<a href="{{ base_url }}/archive.html">Archive</a>
<a href="{{ base_url }}/rss.xml">RSS</a>
</nav>
</header>
<main>
{{{ content }}}
</main>
<footer>
<p>© {{ year }} {{ author }}</p>
</footer>
<script src="{{ base_url }}/assets/js/main.js"></script>
</body>
</html><h1>{{ site_title }}</h1>
<p>Written by {{ author }}</p>
{% for post in posts %}
<article>
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
<time datetime="{{ post.date }}">{{ post.date }}</time>
<p>{{ post.excerpt }}</p>
<a href="{{ post.url }}">Read more</a>
</article>
{% endfor %}<article>
<h1>{{ title }}</h1>
<time datetime="{{ date }}">{{ date }}</time>
<span>by {{ author }}</span>
<div class="post-content">
{{{ content }}}
</div>
{% if tags %}
<div class="tags">
{% for tag in tag_list %}
<a href="{{ tag.url }}">{{ tag.name }}</a>
{% endfor %}
</div>
{% endif %}
</article><h1>Archive</h1>
{% for post in posts %}
<div>
<time datetime="{{ post.date }}">{{ post.date }}</time>
<a href="{{ post.url }}">{{ post.title }}</a>
</div>
{% endfor %}<h1>Tag: {{ tag_name }}</h1>
{% for post in posts %}
<div>
<time datetime="{{ post.date }}">{{ post.date }}</time>
<a href="{{ post.url }}">{{ post.title }}</a>
</div>
{% endfor %}Place your CSS in assets/css/style.css and optional JavaScript in assets/js/main.js. These are copied into public/assets/ at build time.
| Syntax | Description |
|---|---|
{{ variable }} |
Output a variable (HTML-escaped) |
{{{ variable }}} |
Output raw/unescaped HTML (use for content) |
{% for item in list %}...{% endfor %} |
Loop over a list |
{{ item.field }} |
Access a field inside a loop item |
{% if variable %}...{% endif %} |
Conditional block |
{% if variable %}...{% else %}...{% endif %} |
Conditional with else |
In layout.html:
| Variable | Description |
|---|---|
{{ page_title }} |
Title of the current page |
{{ site_title }} |
Site title from config.json |
{{ author }} |
Author from config.json |
{{ base_url }} |
Base URL from config.json |
{{ year }} |
Current year |
{{{ content }}} |
Inner page HTML (from index/post/archive/tag templates) |
In index.html and archive.html:
| Variable | Description |
|---|---|
{{ site_title }}, {{ author }}, {{ base_url }} |
Site-level variables |
posts |
List of posts, each with .title, .date, .slug, .url, .excerpt |
In post.html:
| Variable | Description |
|---|---|
{{ title }} |
Post title |
{{ date }} |
Post date |
{{ author }} |
Author |
{{ slug }} |
Post slug |
{{ tags }} |
Comma-separated tag string |
{{{ content }}} |
Rendered HTML content of the post |
tag_list |
List of tags, each with .name and .url |
In tag.html:
| Variable | Description |
|---|---|
{{ tag_name }} |
Name of the tag |
posts |
List of posts with that tag (same fields as index) |
cblog theme set mytheme
cblog buildThis is the most common approach. You push only the public/ output to a dedicated branch.
Initial setup (one-time):
# From your blog project root, build the site
cblog build
# Initialize a git repo inside public/ pointing to your GitHub repo
cd public
git init
git remote add origin git@github.com:YOUR_USERNAME/YOUR_REPO.git
git checkout -b gh-pages
git add -A
git commit -m "Initial deploy"
git push -u origin gh-pagesSubsequent deploys:
# Rebuild the site
cblog build
# Push updates
cd public
git add -A
git commit -m "Update site"
git push origin gh-pagesEnable GitHub Pages:
- Go to your repository on GitHub
- Navigate to Settings > Pages
- Under Source, select the
gh-pagesbranch and/ (root)folder - Click Save
- Your site will be live at
https://YOUR_USERNAME.github.io/YOUR_REPO/
If you prefer keeping everything in one branch, configure cblog to output into docs/:
# Edit config.json and change output_dir{
"output_dir": "docs"
}cblog build
git add -A
git commit -m "Update site"
git push origin mainThen in GitHub Settings > Pages, select the main branch and /docs folder.
Create a deploy.sh at your project root for one-command deploys:
#!/bin/bash
set -e
echo "Building site..."
cblog build
cd public
if [ ! -d .git ]; then
git init
git remote add origin git@github.com:YOUR_USERNAME/YOUR_REPO.git
git checkout -b gh-pages
fi
git add -A
git commit -m "Deploy: $(date '+%Y-%m-%d %H:%M:%S')"
git push -f origin gh-pages
echo "Deployed successfully!"chmod +x deploy.sh
./deploy.shIf your site is hosted at https://username.github.io/repo-name/ (i.e. not a custom domain), you must set base_url in config.json so that CSS, JS, and links resolve correctly:
{
"base_url": "/repo-name"
}If using a custom domain or a user site (username.github.io), leave base_url empty:
{
"base_url": ""
}.nojekyll— Automatically created inpublic/to prevent GitHub from processing the site with Jekyll- Relative URLs — All internal links use
base_urlso they work under any path prefix - Pure static output — No server-side logic; everything is plain HTML/CSS/JS
MIT