npm.io
0.2.2 • Published 12h agoCLI

md-static-builder

Licence
ISC
Version
0.2.2
Deps
4
Size
115 kB
Vulns
0
Weekly
431

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 build

Features

  • 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 @graph references
  • Content-hashed CSS/JS via esbuild
  • HTML, CSS, and JS minification
  • ZIP export for easy deployment
  • Simple CLI interface

Requirements


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-project

Dependencies: 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 build

This 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 build

Supported 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 build

Running the tests

sb test
# or
npm test

The 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.mden) | | 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. bgfr):

  1. Rename files — rename every bg.md to fr.md in pages/ and posts/
  2. Flag SVG — replace sources/resources/flag-bg.svg with flag-fr.svg
  3. sources/index.html — update the detection and fallback link:
    • navigator.language.startsWith('bg')navigator.language.startsWith('fr')
    • <a href="bg/"><a href="fr/">
  4. sources/404.html — update the regex and fallback links:
    • /^\/(en|bg)\///^\/(en|fr)\//
    • <a href="bg/"><a href="fr/">
  5. Frontmatter — update nav labels, title, and other content from Bulgarian to French
  6. Index metadata — update sources/pages/index/fr.md frontmatter 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:

  1. localStorage preference (set when switching language)
  2. navigator.language
  3. 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 from localStoragenavigator.language → English fallback, then redirects to /{lang}/about so 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:

  1. @media (prefers-color-scheme: dark) on :root — OS-level default, specificity (0,1,0)
  2. html.dark — user explicitly toggled dark, specificity (0,2,0) beats @media
  3. html.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:

  1. 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.
  2. 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-matter for 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

Keywords