md-static-builder
md-static-builder
A CLI tool that converts Markdown with YAML frontmatter into a multilingual static site — auto-generated index, RSS feeds, sitemap, clean URLs, dark mode, and zip packaging.
npx md-static-builder init my-site
cd my-site && sb buildFeatures
- Markdown-based content with YAML frontmatter
- Multilingual site support
- Clean URL structure (extensionless paths)
- JS-independent dark mode with three-state CSS (
@media,html.dark,html.light) - JS-independent language switching via
<a>tag - CSP auto-hashing for all inline
<script>and<style>blocks (including<noscript>) - Auto-generated index, RSS feeds, sitemap
- Missing translation placeholders
- JSON-LD structured data (WebSite, Person, WebPage, AboutPage, BlogPosting) with
@graphreferences - Content-hashed CSS/JS via esbuild
- HTML, CSS, and JS minification
- ZIP export for easy deployment
- Simple CLI interface
Requirements
- Node.js v18 or later
Installation
The tool can be used via npx (no install needed), installed globally, or added as a project dependency:
npm install -g md-static-builder # global install
# or
npm install --save-dev md-static-builder # per-projectDependencies: esbuild, markdown-it, markdown-it-footnote, zip-a-folder.
Quick start
Create a new site with the starter scaffold:
sb init my-site
cd my-site
sb buildThis generates release/ with a fully functional multilingual site. Edit files under sources/ to customize.
Folder structure
my-site/ # Your content repo
├── package.json # Dependencies and scripts
└── sources/ # All editable source files
├── _headers # CSP + security headers (auto-hashed on build)
├── index.html # Language redirect (copied to release/)
├── 404.html # Language-aware 404 redirect (copied to release/)
├── resources/ # CSS, JS, images (copied to release/ on build)
├── templates/
│ ├── wrapper.html # Shared HTML shell (<head>, <body> wrapper)
│ ├── page.html # Inner template for pages
│ ├── post.html # Inner template for posts
│ ├── missing.html # Placeholder for missing translations
│ ├── nav.html # Navigation sidebar + mobile overlay
│ ├── footer.html # Shared footer
│ └── styles.html # Style test page (exported with --styles)
├── pages/ # Static pages — one folder per page
│ ├── index/ # Index metadata (frontmatter only)
│ │ ├── en.md
│ │ └── bg.md
│ ├── about/
│ │ ├── en.md
│ │ └── bg.md
│ └── 404/ # 404 page per language (hide: true)
│ ├── en.md
│ └── bg.md
└── posts/ # Blog posts — one folder per post
└── hello-world/
├── en.md
└── bg.md
Output goes to release/ — wiped and regenerated on every build.
Usage
sb init <dir> # Scaffold a new site in <dir>
sb build # Build all posts and pages
sb build --styles # Also build style test pages for each language
sb build --no-zip # Skip zip packaging (slightly faster iteration)
sb build --no-minify # Skip minification (useful for debugging)
sb test # Run test suite (39 unit tests)Configuration
Settings are read from sb.config.json in the project root, then environment variables, then defaults. CLI flags take highest priority.
sb.config.json:
{
"siteUrl": "https://mysite.com"
}Or with the SITE_URL environment variable:
SITE_URL=https://mysite.com sb buildSupported options:
| Option | Default | Description |
|---|---|---|
siteUrl |
https://example.com |
Base URL for canonical links, sitemap, and RSS |
sourcesDir |
./sources |
Source content directory |
releaseDir |
./release |
Output directory |
noZip |
false |
Skip zip packaging |
isStyles |
false |
Build style test pages |
noMinify |
false |
Skip HTML minification |
Two-repo workflow
The tool and your content live in separate repositories:
- md-static-builder — the CLI tool (this repo, publishable to npm)
- my-site — your content (private or public)
Your content repo's package.json depends on the tool:
{
"name": "my-site",
"private": true,
"scripts": {
"build": "sb build",
"test": "sb test"
},
"dependencies": {
"md-static-builder": "^1.0.0"
}
}Run builds from the content repo:
cd my-site
npm install
npm run buildRunning the tests
sb test
# or
npm testThe suite covers 6 utility functions in lib/utils.js:
| Function | Tests | Description |
|---|---|---|
parseFrontmatter |
13 | YAML parsing, CRLF, trailing newline, empty frontmatter, lists, booleans, numeric, whitespace |
indentHtml |
4 | Default/custom indentation, empty lines, empty input |
wrapSections |
8 | h2 section wrapping, attributes, preamble, consecutive, no h2, trailing |
render |
8 | Placeholder substitution, empty values, unknown key, literal braces |
escapeXml |
4 | Special characters, safe strings, numbers, no double-escape |
minifyHtml |
5 | Comments, whitespace collapse, trim, space collapse, multiline |
All 39 tests use Node's built-in assert module. A non-zero exit code is returned on any failure.
How pages and posts work
Frontmatter
Every Markdown file starts with a YAML block between --- delimiters:
---
title: About
lang: en
nav: About
hide: false
date: 2026-06-01
tags:
- tag1
- tag2
---| Field | Required | Applies to | Description |
|---|---|---|---|---|
| title | Yes | All | Page <title> and auto-generated H1 heading |
| lang | Yes | All | Language code, matches the filename (en.md → en) |
| nav | Yes | Pages | Label shown in the navigation sidebar |
| hide | No | All | When true, excludes from sitemap (and from the index if it is a post) |
| date | Yes | Posts | Publication date — used for sorting and RSS |
| tags | No | Posts | List of tags, available as {{tags}} in templates |
| author | No | All | Author name, per-post overrides site-wide default |
| description | No | All | Meta description and Open Graph description, per-post overrides site-wide default |
| og_image | No | Index | Open Graph image URL (site-wide, set in index frontmatter) |
| author_image | No | Index | Person schema image URL (site-wide, set in index frontmatter) |
| og_site_name | No | Index | Open Graph site name (site-wide, set in index frontmatter) |
| sameAs | No | Index | List of profile URLs (e.g. GitHub, LinkedIn) for Person schema |
The build script discovers files by scanning subdirectories in sources/pages/ and sources/posts/ for files named {lang}.md. Files in pages/ use page.html; files in posts/ use post.html.
Adding a new page
Create a folder under sources/pages/ with one Markdown file per language:
sources/pages/contact/
├── en.md
└── bg.md
The nav label is read from the nav frontmatter field — the {{nav_contact}} and {{active_contact}} placeholders are generated automatically. No template editing required.
Adding a new post
Same structure but under sources/posts/:
sources/posts/my-post/
├── en.md
└── bg.md
Languages
The scaffold ships with two languages (en and bg). To replace one (e.g. bg → fr):
- Rename files — rename every
bg.mdtofr.mdinpages/andposts/ - Flag SVG — replace
sources/resources/flag-bg.svgwithflag-fr.svg sources/index.html— update the detection and fallback link:navigator.language.startsWith('bg')→navigator.language.startsWith('fr')<a href="bg/">→<a href="fr/">
sources/404.html— update the regex and fallback links:/^\/(en|bg)\//→/^\/(en|fr)\//<a href="bg/">→<a href="fr/">
- Frontmatter — update
navlabels,title, and other content from Bulgarian to French - Index metadata — update
sources/pages/index/fr.mdfrontmatter with language-specific strings (theme_label,font_label,missing_translation, etc.)
The build script discovers available languages automatically from the pages/index/ directory.
Site-wide strings
Per-language strings like theme_label and font_label live in the index page's frontmatter (sources/pages/index/{lang}.md). Any field other than title, lang, nav, hide, date, tags, and author becomes a {{placeholder}} in templates. Fields like author and description can also be set per-post — the per-post value overrides the site-wide default from the index page. Fields like author_image and sameAs (a YAML list of profile URLs) are used in JSON-LD structured data.
Missing translations
If a page exists in one language but not another (e.g. about/en.md exists but about/bg.md does not), the build generates a placeholder page using missing.html with the {{missing_translation}} message. The nav label is inherited from the available language. Add the message to each index page's frontmatter:
missing_translation: This page is not available in this language.Template system
Templates live in sources/templates/ and use {{placeholder}} syntax:
wrapper.html — outer HTML shell:
<html lang="{{lang}}">
<head>
<link rel="canonical" href="{{canonical}}">
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/{{lang}}/feed.xml">
<link rel="alternate" hreflang="{{lang}}" href="/{{lang}}/{{page}}">
<link rel="alternate" hreflang="{{lang_target}}" href="{{lang_target_href}}">
<link rel="icon" href="../resources/icon.png" type="image/png">
<link rel="apple-touch-icon" href="../resources/apple-touch.png">
<meta name="author" content="{{author}}">
<meta name="description" content="{{description}}">
<meta name="theme-color" content="#fff">
<meta property="og:type" content="{{og_type}}">
<meta property="og:title" content="{{title}}">
<meta property="og:description" content="{{description}}">
<meta property="og:url" content="{{canonical}}">
<meta property="og:image" content="{{og_image}}">
<meta property="og:site_name" content="{{og_site_name}}">
<meta property="og:locale" content="{{og_locale}}">
<meta property="og:locale:alternate" content="{{og_locale_alternate}}">
<meta name="twitter:card" content="summary_large_image">
<script>/* restore theme/font preferences, set theme-color */</script>
<script type="application/ld+json">{{json_ld}}</script>
<link rel="stylesheet" href="../resources/styles.css">
<noscript><style>.theme-btn,.font-btn{display:none}</style></noscript>
</head>
<body>
{{inner}}
<script src="../resources/script.js" defer></script>
</body>All styling, including dark-mode variable overrides, lives entirely in styles.css.
page.html / post.html — inner content:
<div id="progressBar"></div>
{{nav}}
<main>
{{content}}
</main>
{{footer}}Available placeholders:
| Placeholder | Value |
|---|---|
{{lang}} |
Language code (en, bg, …) |
{{title}} |
Page title from frontmatter |
{{page}} |
Page slug (about, index, 19-years, etc.) |
{{active_{page}}} |
class="active" on the current page, empty otherwise |
{{nav_{page}}} |
Nav label from page frontmatter |
{{content}} |
Rendered HTML from Markdown |
{{date}} |
Post date (posts only) |
{{tags}} |
Comma-separated tags (posts only) |
{{author}} |
Author from frontmatter (per-post overrides site-wide default) |
{{description}} |
Meta/OG description from frontmatter (per-post overrides site-wide) |
{{og_image}} |
Open Graph image URL (site-wide, from index frontmatter) |
{{og_site_name}} |
Open Graph site name (site-wide, from index frontmatter) |
{{og_locale}} |
Open Graph locale (per-language, from index frontmatter) |
{{og_locale_alternate}} |
Open Graph alternate locale (per-language, from index frontmatter) |
{{nav}} |
Rendered navigation partial |
{{footer}} |
Rendered footer partial |
{{canonical}} |
Canonical URL (extensionless) |
{{lang_target}} |
Opposite language code (e.g. bg on an en page) |
{{lang_target_href}} |
Full path to the opposite language version (e.g. /bg/about) |
{{lang_flag_alt}} |
Uppercase target language (BG, EN) |
{{json_ld}} |
JSON-LD structured data block (injected into <script type="application/ld+json">) |
{{site_url}} |
Base site URL from config (e.g. https://stoinov.com) |
{{og_type}} |
"website" for pages, "article" for posts |
Language redirect
sources/index.html redirects first-time visitors based on:
localStoragepreference (set when switching language)navigator.language- English fallback
404 page
Each language gets a 404 page at sources/pages/404/{lang}.md with hide: true (excluded from sitemap). The static release/404.html checks the URL path:
- Has language prefix (
/en/about) — page not found → redirects to/{lang}/404 - No prefix (
/about) — detects language fromlocalStorage→navigator.language→ English fallback, then redirects to/{lang}/aboutso the clean URL gets a language prefix
Content Security Policy
The starter scaffold (sb init) ships with a _headers file that includes a strict default CSP plus security headers (Strict-Transport-Security, X-Frame-Options, Permissions-Policy, etc.).
On every build, the tool automatically computes SHA-256 hashes of every inline <script> and <style> block across all HTML pages (including those inside <noscript>) and injects them into the Content-Security-Policy directive in that file.
For each directive found (e.g. script-src), the hashes are appended to the existing value. If no CSP line exists at all, one is added with default-src 'self' plus the hashes.
You can edit sources/_headers to tighten or loosen the policy — the build will fill in the 'sha256-...' tokens automatically:
default-src 'none'; script-src 'self'; style-src 'self'
and the build will fill in the required 'sha256-...' tokens automatically, keeping the policy tight while allowing your known inline code.
Server config for the 404 redirect:
nginx:
error_page 404 /404.html;
Apache:
ErrorDocument 404 /404.html
Caddy:
handle_errors {
@404 `{http.error.status_code} == 404`
rewrite @404 /404.html
}
Clean URLs
All internal links use extensionless paths (/en/about). Files stay as .html on disk. The web server must map extensionless requests to .html files:
nginx:
try_files $uri $uri.html $uri/ =404;
Apache:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)$ $1.html [L]
Caddy:
try_files {path} {path}.html {path}/ 404
User preferences
| Preference | Default | Storage key |
|---|---|---|
| Theme | OS prefers-color-scheme |
theme (dark / light) |
| Font | Sofia Sans | font (garamond / unset) |
| Language | Browser language → English | lang (language code) |
Dark mode uses a three-state CSS cascade:
@media (prefers-color-scheme: dark)on:root— OS-level default, specificity (0,1,0)html.dark— user explicitly toggled dark, specificity (0,2,0) beats@mediahtml.light— user explicitly toggled light in a dark OS, specificity (0,2,0) beats@media
The toggle button removes both dark/light classes and adds the opposite, and updates the <meta name="theme-color"> tag so the browser chrome matches (supported in Chrome/Android/Firefox; on Safari the chrome updates on the next page load). When JavaScript is disabled, the OS preference applies automatically and theme/font buttons are hidden via <noscript><style>. Transitions are applied via JavaScript only during manual toggles — no load-time animation.
Markdown content format
Standard Markdown with markdown-it and markdown-it-footnote. Each ## section is automatically wrapped in a <div class="content-block"> for card styling:
# Page Title
Introductory paragraph not inside a card.
## Section One
Content for the first card.
## Section Two
Content for the second card.Footnotes
Pandoc-style footnotes:
This is a claim.[^1]
[^1]: First footnote text.Inline footnotes: A quick aside^[inline footnote text]
Reasoning
The reason I went with yet another custom static site builder is two-fold:
- I needed a small project to experiment with using local AI models. I also needed to update my site for a while, so those two goals converged naturally.
- I wanted to begin migrating away from US hosted services and providers, so this project was largely aimed at using the statichost.eu provider. This explains the built-in ZIP export and the path/naming structure.
Another consideration was that none of the existing site builders were simple enough for my liking. When they were simple, they were often too limited. I needed multilingual support, which was usually missing unless you went with commercial products that are, frankly, overkill for my modest needs.
To-do
Potential optimizations, in no particular order:
- Robust frontmatter parsing — replace the custom YAML parser with
gray-matterfor proper edge-case handling - Parallel/async build — use async I/O and/or parallel processing for faster builds on larger sites
- Image optimization — compress and resize images in the resources directory during build
- Watch mode — re-build automatically when source files change