- Ruby 86.2%
- JavaScript 6.7%
- CSS 3.8%
- Shell 2.1%
- HTML 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .gitea/workflows | ||
| assets | ||
| build | ||
| config | ||
| content.nosync | ||
| docs | ||
| lib | ||
| locales | ||
| media.nosync/2026 | ||
| scripts | ||
| templates | ||
| write | ||
| .gitignore | ||
| blog.sh | ||
| CHANGELOG.md | ||
| env.sh.example | ||
| import.sh | ||
| LICENSE | ||
| NOTICE | ||
| README.md | ||
| setup.sh | ||
| style.sh | ||
blog.sh — .sh → .rb → .html
minimalistic static web/log cms
A minimalist, file-based web/log engine. Posts are plain JSON files, the site is a static build, and authoring happens through a CLI/wizard -- no database, no admin server, no PHP.
Who it's for: one person writing their own blog, at home in a terminal, who wants to own the whole archive -- including everything they already wrote somewhere else. Twenty-two import sources bring it in, from a WordPress export to a blog whose platform no longer exists. It deploys to Surfer, rsync, git-pages, rclone, SFTP or a local directory.
Who it isn't for: several authors sharing one site, anyone who needs a web admin interface, or a workflow where publishing isn't a command. It grew around a single deployment (sean.cz) and still fits that shape best, but it installs and runs as-is --
setup.shasks the questions, and blogsh.app is a second site running the same unmodified engine.
See it running: blogsh.app is this engine
publishing its own documentation -- every page there was built and
deployed by ./blog.sh itself.
MIT licensed (see LICENSE); the fonts and brand marks it ships belong to other people, and NOTICE says who.
| Light | Dark |
|---|---|
![]() |
![]() |
The default blue palette -- both modes come entirely from the 7-key
colors: section in config/site.yml (see
install.md → The palette and the header's type).
Contents: Why this exists · What it does · Stack · Structure · Requirements · Getting started · Authoring · Configuration · Importing existing content · Exporting · Deploy · Roadmap · Example deployments
Why this exists
The build, the authoring tool and the deploy step are one program, built around one workflow rather than around the general case.
- A static generator. One build, plain files out. No database, no server, no request-time rendering.
- Markdown is the form you write in, not what is stored. On save it is parsed once into typed blocks -- paragraph, heading, quote, list, table, code, image, video, audio, chat, link, divider. The build never parses Markdown again, and every importer targets that one schema.
- The Fediverse is the comment system. A published post is announced
on Mastodon or Bluesky; replies to that announcement are the comments,
fetched from the network's public API. Nothing to moderate, migrate or
host. Favourite a reply to publish it (
comments.approval: fav). - The archive is a directory of JSON files. One post, one file. It is readable, greppable and backed up by copying it.
- Authoring is a command.
./blog.shwrites, edits, publishes, schedules and deploys. A phone writes at/write/and sends over SSH. - The deploy is paranoid. A SHA-256 manifest ships only what changed, and refuses when the file count or the byte total swings too far from the last build it accepted.
- Nothing calls a third party from the reader's browser. Sidebar widgets are fetched server-side on a cron.
What you give up: themes, plugins, and a second author.
What it does
Each part says where it is described in full.
- Content. One post is one JSON file,
content.nosync/posts/<year>/<slug>.json, holding typed blocks. Drafts, scheduled posts, pages, unlisted posts, series, tags and a trash with restore. → architecture.md - Writing.
./blog.shbare is a menu; every command also works directly: add, edit, publish, schedule, unpublish, delete, restore, rename, re-announce.add <file>takes a markdown file and asks nothing;--jsonanswers as one object. → operations.md - From a phone.
/write/is a page on the blog itself: title, text, tags, photographs, video. It sends over the SSH the server already has, publishes, and polls for the answer. → operations.md - Markdown. One parser, shared by the build and the authoring tool, with a cheat sheet the parser generates for your own site at
/markdown/. Editing a post round-trips through it. → operations.md - Search, and the other ways to find a post. Full text over the whole archive, entirely in the
reader's browser -- no server, no index service, nothing called from outside, on a site of any
size. Quoted phrases,
-wordto exclude, and diacritics never decide a match, so nemecko finds Německo. The index is split so the page is not made to wait for it: the recent half loads with the page, the archive only when a search reaches past it -- on a 6,600-post site, 432 kB against 4.3 MB. Beside it,/archive/maps the whole site in two levels and/tag/lists every tag with its count. → architecture.md, operations.md - Build. Static HTML from JSON through ERB templates: stable pagination, tag, series and type archives, RSS, sitemap,
robots.txt, a generated favicon, a 404 in the site's own chrome. A page whose inputs have not moved is not rendered again. → architecture.md - Deploy. Cloudron Surfer, a local directory, rsync over SSH, a git-pages push, any rclone remote, or SFTP. A SHA-256 manifest ships only what changed and refuses a build that swings too far from the last one. → operations.md
- Comments. The announcement's replies are the thread;
comments.approval: favpublishes only the ones you favourite. → Why this exists - Sidebar widgets. Toots, Bluesky posts, Pixelfed posts, commits, any RSS feed, per-post stats -- each optional, all fetched server-side on a cron. → operations.md
- Appearance. Light and dark from CSS custom properties, a three-state toggle, and a seven-key palette in
config/site.ymlthat the wizard previews before writing. A listing card is cut to a size budget as it is written rather than drawn in full and hidden with CSS. → install.md - Reachable without a mouse. One focus ring for the whole site, skip links, and motion that respects
prefers-reduced-motion. → architecture.md - Security. A Content-Security-Policy on every page, self-hosted fonts, nothing third-party in post data,
env.shout of git at mode 600. → architecture.md - Importing. Twenty-two sources through
./import.sh, always previewed in dry-run before anything is written, landing in the same schema as a hand-written post. → importing.md - Exporting. The archive leaves as markdown with front matter, or as JSON. → operations.md
Stack
- Build: Ruby (
build/build_blog.rb) - Authoring: a Ruby CLI/wizard (
scripts/manage_post.rb, run via./blog.sh) - Templates: ERB (
templates/) - i18n:
locales/*.yml+lib/i18n.rb-- ships with English (default), Czech and German; add anotherlocales/<code>.ymlfor a different language, missing keys fall back to English - Deploy: pluggable backends (
lib/deploy_backend/) -- Cloudron Surfer (Files API), a local directory, rsync, git-pages, rclone, or SFTP;scripts/deploy_web.rb - Sidebar widgets: entirely optional,
lib/*_fetcher.rb+lib/sidebar.rb
Structure
blog.sh Main tool -- CLI and interactive wizard (see below)
setup.sh Setup wizard -- identity, address, comments network, deploy target
style.sh Appearance wizard -- palette, banner, menu, about, footer, sidebar
import.sh Import wizard -- pick a source, preview, confirm (see below)
build/ Build script (JSON posts -> static HTML)
scripts/ Ruby CLI, import/deploy scripts, and their .sh wrappers:
deploy-web.sh standalone deploy of public.nosync/ to the configured target (no rebuild)
refresh-sidebar.sh cron: refreshes only the sidebar widgets (no site rebuild)
publish-scheduled.sh cron: publishes whatever the queue has come due
receive.sh takes a whole post over one SSH connection into incoming/
migrate_*.rb one per import source, scriptable alternative to import.sh
lib/ Shared Ruby libraries (Surfer client, fetchers, post writer, i18n, ...)
lib/import/ Import adapters plus the layer they share (media, run, CLI)
locales/ UI strings for the generated site and the CLI (en.yml, cs.yml, de.yml)
templates/ ERB templates (layout, post, index, search, partials)
+ markdown-cheat-sheet.<lang>.md, the /markdown/ page's source
assets/ CSS/JS/fonts (drop your own images into assets/images/)
write/ The page served at /write/ when write: true -- the editor itself, its
locale sources and the script that turns them into i18n.js
config/site.yml.example Documented config template -- copy to config/site.yml (gitignored) per deployment
env.sh.example Documented secrets/env template -- copy to env.sh (gitignored) per deployment
docs/ Install, operations, importing, architecture, decisions, skinning and
localization guides; this README's screenshots; and shortcuts/,
the two iOS shortcuts that send a post from a phone
content.nosync/, media.nosync/, public.nosync/, incoming/, trash/, drafts/, env.sh, config/site.yml
Per-deployment/generated, not part of the engine -- see .gitignore
content.nosync/posts/ (the posts themselves) and media.nosync/ (their
images/videos) are deliberately not part of this repo: they're personal
content, not code, specific to whoever deploys this engine for their own
site. Likewise config/site.yml -- only the documented
config/site.yml.example template is committed. The .nosync suffix also
excludes both directories from iCloud sync on a Mac; on a server, where
iCloud doesn't exist, it's just a name.
Requirements
- Ruby 2.7+ (3.x recommended), standard library only -- no gems, no Bundler, nothing to
install. One caveat: the optional Pixelfed/RSS sidebar widgets use
rexml, a Ruby default gem (ships with a normal Ruby install, but some distro package splits leave it out -- see install.md ifgem install rexmlis ever needed). Importing needs it for real: every XML source -- WordPress, Blogger, Squarespace, podcasts, any RSS or Atom feed, the Wayback rescue and LiveJournal -- reads throughrexml - bash (the thin
blog.sh/deploy-web.sh/refresh-sidebar.shwrappers) - Optional, per integration: somewhere to deploy to (a
Cloudron Surfer app, any rsync/SSH host, a
GitHub/GitLab/Codeberg Pages branch, an rclone remote, or just a local
directory served by your own web server), a Mastodon or Bluesky
account for comments and the auto-announcement, cron for the sidebar
widgets -- and, only if you turn on
media.convert_heic(converting iPhone HEIC photos to JPEG on save), an image tool the machine typically already has:sips(built into macOS),heif-convert, ImageMagick or vips. Off by default; without a tool the engine refuses the file with instructions instead of breaking.media.remux_video(repacking a phone video so it starts playing before it has finished downloading) asks for ffmpeg in the same spirit: off by default, and without it the post is saved anyway
Getting started
With Ruby 2.7+ on the machine. Coming from zero, there is a copy-paste path per platform in install.md → Quick start.
./setup.sh # title, address, timezone, comments network, deploy target
./style.sh # palette, banner, bio, footer, sidebar
./blog.sh doctor # is the configuration sound?
./blog.sh add # write the first post
./blog.sh rebuild # build and deploy
./blog.sh preview # or look at it locally first, on :8000
Both wizards show a diff and write nothing until you confirm, and
re-running either is how you change any of it later. By hand instead:
copy config/site.yml.example and env.sh.example (chmod 600 env.sh)
and edit them -- an unedited pair is already a working local site.
Everything beyond the core -- analytics, each sidebar widget, comments and
the announcement -- activates only when its config section is present.
Replace assets/images/favicon.png with your own artwork; it and the
banner are gitignored, so they survive git pull and nothing else keeps a
copy of them.
The whole path -- server install, every deploy backend, the phone
workflow, the comments network -- is install.md;
day-to-day usage is operations.md. What changed
between versions is CHANGELOG.md, and ./blog.sh version
says what you are running.
blog.sh -- authoring
./blog.sh # interactive wizard (menu)
./blog.sh add [<file>] [--json] [--untrusted]
# creates a draft, shows a preview, asks what's next;
# with a markdown file it asks nothing, and --json answers as data;
# --untrusted refuses a picture reference that is not a bare filename
./blog.sh edit [<slug>] # without a slug, offers the last 50 posts
./blog.sh props [<slug>] # a post's state and its actions; [e] changes what the post IS --
# its series and part, tags, type, and the unlisted/hero/toc flags
./blog.sh publish [<slug>] [--yes] [--no-announce] [--json]
# shows the draft's preview, asks what's next;
# --yes publishes without asking, --no-announce keeps it off Mastodon and Bluesky;
# --json (with --yes) answers as one object, for a script or a phone
./blog.sh schedule [<slug>] # asks for a date, then auto-publishes the draft when it arrives
./blog.sh queue # what is scheduled, when each one goes, and the slots it uses
./blog.sh unpublish [<slug>] # moves a published post back to draft (also deletes its announcement)
./blog.sh delete [<slug>] # deletes a post to trash/
./blog.sh restore [<slug>] # restores a post from trash
./blog.sh empty trash # deletes everything in the trash, for good
./blog.sh empty versions # keeps each post's newest version, removes the older ones
./blog.sh toot [<slug>] # (re-)sends the comment toot (Mastodon sites)
./blog.sh bluesky [<slug>] # (re-)sends the announcement (Bluesky sites)
./blog.sh rebuild [--full] # rebuilds and deploys the whole site;
# --full builds every page again instead of only the changed ones
./blog.sh preview [<port>] # serves public.nosync locally (default 8000)
./blog.sh browse [--type=image] [--tag=foo] [--drafts]
# the archive on screen: filters, search, preview, Enter opens the post
./blog.sh list [--type=image] [--tag=foo] [--drafts]
# the same, printed one line per post
./blog.sh doctor [--online] # reads the configuration and says what is wrong with it
./blog.sh doctor --strip-location
# removes the place of capture from photos already in the archive
./blog.sh check [--online] [--json] [--repair]
# walks the archive and says what is broken in it;
# --json prints every finding as data instead of a screenful;
# --repair offers, per finding, the one repair that finding allows
./blog.sh export [<dir>] [--no-drafts] [--dry-run] [--force]
# writes the whole archive out as a tree of markdown files
./blog.sh stats [--json] # counts the archive: posts by year and kind, words, tags, media, sources
./blog.sh version # which version this installation is running
./blog.sh help
Configuration (env.sh, per-deployment, never in git)
Copy env.sh.example to env.sh (gitignored) and fill it
in -- chmod 600 env.sh, since it holds live credentials. It lives wherever
the site is built from: on a server for a deployed site, or just on your
laptop for a local one.
export SITE_BASE_URL=https://example.com
export MASTODON_ACCESS_TOKEN=... # comment toots (optional)
export TUMBLR_API_KEY=... # importing a Tumblr blog (wizard or script)
export DEPLOY_BACKEND=... # surfer (default) | local | rsync | git | rclone | sftp
export SURFER_URL=... # surfer backend
export SURFER_TOKEN=...
export SURFER_REMOTE_DIR=...
export DEPLOY_TARGET_DIR=... # local backend
export RSYNC_TARGET=... # rsync backend (+ optional RSYNC_SSH)
export GIT_PAGES_REMOTE=... # git backend (+ optional GIT_PAGES_BRANCH/_CNAME)
export RCLONE_TARGET=... # rclone backend (+ optional RCLONE_ARGS)
export SFTP_TARGET=... # sftp backend (+ optional SFTP_REMOTE_DIR/SFTP_ARGS)
Importing existing content
./import.sh # pick a source, preview, confirm
An imported post is indistinguishable from one you typed: same block schema, media downloaded locally. The wizard previews in dry-run before it writes, and re-running is safe -- posts are matched on their source id and overwritten in place.
| Source | Needs | Scope |
|---|---|---|
| beehiiv | the posts CSV export | newsletters and drafts; paid issues arrive as drafts |
| Blogger | the Atom backup file | posts and drafts; comments skipped, images at full size |
| Bluesky | nothing (public API) | your standalone posts; replies, reposts, quotes skipped |
| an unpacked export | your posts with their photos and video; crossposts skipped | |
| Ghost | the JSON export + the live site's URL | every post and page, drafts included |
| an unpacked export | your grid and IGTV; stories and archived posts skipped | |
| Jekyll/Hugo | the site tree (or any markdown folder) | posts and drafts with front matter; relative image paths come from the tree, absolute URLs download one request each -- import while the old host answers |
| LiveJournal | LJ_PASSWORD |
every entry via the API; friends-only arrive as drafts |
| Mastodon | an unpacked account archive | standalone posts; boosts and replies skipped |
| Medium | an unpacked export | posts and drafts; responses to others become drafts |
| Movable Type/TypePad | the MT export file | posts and drafts; redirects need a URL pattern |
| Pixelfed | a statuses export | standalone posts with their photos |
| Podcast | a feed URL | every episode, audio and artwork hosted locally |
| Squarespace | the "WordPress format" XML export | posts, drafts and pages, feature images included |
| Substack | an unpacked export | newsletters and podcasts; paid posts tagged substack-paid |
| Threads | an unpacked export | your standalone posts; replies to others skipped |
| Tumblr | TUMBLR_API_KEY |
every published post; a reblog keeps its trail |
| Twitter/X | an extracted archive export | standalone tweets; replies, RTs and quotes skipped |
| Wix | the blog CSV export | posts and drafts; unsupported node types counted by name |
| Wayback Machine | the dead blog's old URL | whatever the Archive saved, reassembled oldest-first |
| WordPress | a WXR export file | every post and page with its featured image and captions |
| RSS/Atom | a feed URL | whatever the feed carries, usually its last few dozen items |
One thing to decide before the first run: KEEP_PERMALINKS=1 writes the
redirect_from list that keeps the old site's links working. Without it an
archive imports with no redirects at all, and the only way back is another
import.
What each source skips and why, the traps per platform, the variables, the non-interactive form and how to undo an import: importing.md.
Exporting
./blog.sh export [<dir>] [--no-drafts] [--dry-run] [--force]
An archive you cannot take with you is not yours. The whole thing comes
out as markdown with YAML front matter, in the layout most other engines
read -- and ./import.sh reads that tree back, posts keeping their
identity, series, redirects and media, so export plus re-import is also
how an installation moves.
→ operations.md
Deploy
ruby build/build_blog.rb # rebuild into public.nosync/
./scripts/deploy-web.sh # uploads only new/changed files (SHA256 manifest)
./scripts/deploy-web.sh --prune # also deletes orphaned files on the target
./blog.sh rebuild does both steps at once.
Cron (sidebar widgets and post stats)
The sidebar widgets and per-post stats are refreshed by
scripts/refresh-sidebar.sh -- it fetches the data, rewrites only the
JSON files (six at most: toots, Pixelfed, commits, Bluesky, RSS, stats)
and uploads just the ones the site's configured widgets produce, no site
rebuild. Run it from cron wherever the site is built:
*/30 * * * * /path/to/blog.sh/scripts/refresh-sidebar.sh
Every 30 minutes is plenty -- the data it refreshes (recent toots,
Pixelfed posts, commits, like/boost counts) doesn't move faster than
that. Skip the cron entirely if no widgets are configured -- unless
comments are moderated (comments.approval: fav): approved replies
reach the site through this same job (it writes comments.json too),
so a moderated site needs it with or without widgets.
A second, optional job powers ./blog.sh schedule -- it publishes
scheduled drafts whose date has arrived (and does nothing otherwise):
*/15 * * * * /path/to/blog.sh/scripts/publish-scheduled.sh >/dev/null
Roadmap
What is not built, and why.
- Imports. Twenty-two sources are covered; a new one needs an adapter with three methods, because media, dedup, dry-run and reporting are shared. Not everything in the table has been leaned on equally: pages from Squarespace and Substack rest on sample exports, and LiveJournal has never run against the live service, since it has no export file and the adapter talks to the API.
- Comments on X: rejected. Since February 2026 the API bills per use -- reads $0.005 each, no public access -- so an announcement plus a continuously re-fetched thread would cost money forever on a personal blog.
- Comments on Threads: feasible, deferred. The free API can publish and read replies to your own posts, but only server-side: a Meta developer app, an OAuth dance, 60-day tokens with a refresh cron, and comments cached into JSON on a timer instead of the live threads Mastodon and Bluesky give the reader's browser for nothing. Sketched; waiting for real demand. Scraping it instead is rejected outright -- it breaks without warning and risks the user's account.
Example deployments
sean.cz is the blog this engine was extracted from -- Daniel Šnor's own, and the reference for what a fully configured deployment looks like.
blogsh.app is the project's own site, running the same unmodified engine and publishing its documentation as ordinary posts.
blog.elegantlich.com is the first deployment in hands other than the author's -- the planning blog of the Elegant Lich magazine. Its owner's first-install feedback is in the engine: an announcement that cannot happen now says why.
arch-linux.cz is the community blog of the Czech Arch Linux user group: the stock engine under its own stylesheet, no template edits, and the first site to pair it with GoToSocial -- which is where the GoToSocial comments in 1.4 were found.
blog.oscloud.cz is the news blog of OSCloud, a Czech self-hosting community, skinned the same way. The tag index and the copy button on code blocks were both their requests -- a blog of terminal how-tos wanted them first.
archive.bierfaristo.com is the largest archive we know of running this engine, some 13,700 posts -- a working answer to "does it scale". Its owner has reported more than anyone else outside.


