npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

md-static-builder

v0.2.2

Published

Minimal markdown-to-HTML static site generator with multilingual support, YAML frontmatter, and clean URLs.

Readme

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