Building a Blog Site with Bare Hands---No Magic, No Fuss
TL;DR
This post describes a minimalist approach to build a personal blog site without using any static site generator (like [Hugo] or [Jekyll]) or content management system (like [WordPress]). Instead, contents are written in plain text using a custom, lightweight markup format, then converted into HTML files using a user-defined PowerShell script. The generated HTML files are organized manually and deployed to [GitHub Pages] via a simple, transparent [GitHub Actions] workflow. This method offers complete control over the site's structure, styling, and deployment, favoring simplicity and clarity over convenience and abstraction.
Method
There are three essential parts to this approach: 1. **Folder Organization**---Clean separation of raw content and generated HTML. 2. **Markup Converter**---A PowerShell script that converts plain text into styled HTML using a template. 3. **Deployment Flow**---A GitHub Actions workflow that automates the deployment of the generated HTML files to GitHub Pages.
The Folder Organization
The project follows a simple directory layout:
the-blog-site |- contents | |- <tag>_YYYY_MM_DD_[a-z] <- a single post | | |- <tag>_YYYY_MM_DD_[a-z].txt <- the manuscript of the post | | |- media/ |- artifacts | |- <tag>_YYYY_MM_DD_[a-z] | | |- <tag>_YYYY_MM_DD_[a-z].html <- the generated HTML of the post | | |- media/ |- index.html <- the main page of the blog site
The `artifacts/a-single-post` folder is the output of the conversion process.
The `contents` and `artifacts` folder form the cornerstone of the blog site. The `contents` folder holds the my post manuscripts---each post consists of a text file and an optional `media` folder for images or other assets. The `artifacts` folder mirrors the structure of `contents`---after conversion, each post's HTML file is placed in the corresponding folder under `artifacts`, along with any media files.
Note, each post is named with a pattern `<tag>_YYYY_MM_DD_[a-z]`, where the `<tag>` is a short free-chosen phrase to suit the author's need, and the timestamp `YYYY_MM_DD` together with the postfix `[a-z]` should ensures an unique identifier for the post.
The Markup Conversion
A single post is converted using the command:
pwsh -nop ./build-post.ps1 \ ./artifacts/PD_2025_05_16_a/PD_2025_05_16_a.html \ ./contents/PD_2025_05_16_a/PD_2025_05_16_a.txt \ ./commons/templates/post-template.html
This command runs a PowerShell script `build-post.ps1` that takes three arguments: 1. The output HTML file path. 2. The input text file path. 3. The template HTML file path. It reads the input text file, calls an external tool [mm2html] to parses its lightweight markup (a customized alternative to Markdown), injects the parsed content into an HTML template, outputs a complete HTML file, and saves it to the specified output path.
The whole blog posts are generated by the command:
pwsh -nop ./build.ps1
The PowerShell script `build.ps1` scans the `contents` folder to generates a record of all the names and corresponding versions of the posts, compares it with previous one (if it exists) to determine "add/modify/delete" action for each post, then it calls `build-post.ps1` for each of those categorized into "add/modify", and deletes the corresponding folder from "artifacts" for those categorized into "delete".
The Deployment Flow
This blog site is a static website, hosted on [GitHub Pages].
The repository has two important branches: - `main` branch---Tracks the `contents` folder, but not the `artifacts` folder, used for "authoring". - `gh-pages` branch---Tracks the `artifacts` folder, but almost nothing else, purely for "deployment".
A new push to the `main` branch will trigger a GitHub Actions workflow that: 1. Check out the `main` branch a. Scan for any changes for the "contents" using the freshly collected record and the previous one. b. If necessary, call build scripts to generate the HTML posts into a temporary folder, and leave a list of posts to be deployed. 2. Check out the `gh-pages` branch a. Move the updated HTML posts from the temporary folder to the `artifacts` folder. b. Remove any posts that are marked as "deleted" from the `artifacts` folder. c. Commit the changes to the `gh-pages` branch. d. Push the changes to the `gh-pages` branch, which is configured to be served by GitHub Pages. As a result, only the changed HTML files land in `gh-pages` branch, and the site is updated with minimal footprint.
Discussion
The Post Naming Scheme
The post naming scheme `<tag>_YYYY_MM_DD_[a-z]` is designed to ensure uniqueness and but yet simplicity: - `<tag>` is a short, cryptic phrase, usually consists of two uppercase letters, e.g. `PD`, `LM`, `RF`, etc., which could mean anything or nothing---it may be used to help categorize posts. - `YYYY_MM_DD` provides a clear timestamp, making it easy to sort and locate posts chronologically. - `[a-z]` is a single letter suffix that allows for multiple posts on the same day---yes, at most 26 posts a day, but this capacity is more than enough for my writing pace.
The Incremental Build
The build process is designed to be minimal overhead---a big part of keeping things efficient is not rebuilding every post every time---it only reacts to changes, very much like a sync-and-merge operation. This is achieved thanks to the `@version` metadata embedded in each post manuscript and the unique naming scheme.
For example, if there is a stub of the previous posts like this:
PD_2025_05_21_a <tab> 0.1.0 PD_2025_05_21_b <tab> 0.1.1 PD_2025_05_22_a <tab> 0.1.0
and the newly scanned posts record like this:
PD_2025_05_21_a <tab> 0.1.0 PD_2025_05_22_a <tab> 0.1.1 PD_2025_05_30_a <tab> 0.0.1
then, we can see that: - `PD_2025_05_21_a` is unchanged, so it is skipped. - `PD_2025_05_21_b` is no longer present, so it is marked as "deleted". - `PD_2025_05_22_a` has a new version, so it is marked as "modified". - `PD_2025_05_30_a` is a new post, so it is marked as "added". Note, in internal processing, the `<tag>` part is stripped off, so the remaining identifier is just `YYYY_MM_DD_[a-z]`, which is lexicographically sortable.
The GitHub Actions Tricks
Since two branches are used separately for "authoring" and "deployment", the deployment procedures needs to deal with both branches. The build process is actually split into three parts: 1. Determine the add/modify/delete actions for each post 2. Generate the HTML files for the posts that are marked as "add" or "modify" 3. Delete the posts that are marked as "delete" The first and second parts are done in the `main` branch, while the third part is done in the `gh-pages` branch. A temporary intermediate folder untracked by neither branch is used for data exchange.
Motivation
Most blogging platforms or static site generators aim to simplify publishing workflows. Their core model is you focus on the content and they handle everything else---the form, the deployment, etc. But for someone who believes "form" is also part of the content and wants to have full control over the page structure, design, and behavior, these platforms often feel restrictive: - Folder and file naming rules (e.g. you must put posts in a particular directory, name them a certain way, drop front-matter in YAML, etc.) just does not fit my feet. - Predefined templates and themes are pretty, but are too often too overly complicated. - Even Markdown itself began to feel like a constraint when I wanted to tweak formatting beyond its defaults.
Having worked with Jekyll, Hugo, and friends, I found myself constantly working against the grain of their conventions. And I don't want to wrestle with Markdown flavors or build systems. Therefore, I decided to build my own blog site from scratch, keeping the pipeline as simple and transparent as possible.
Building a blog with "bare hands" might sound old-school and daunting, but it is delightful for me---I desire maximal freedom of creation.