./blog.sh manual

Start here

The main README is the tour: what the engine is, what it can do, and why it's built the way it is. This manual holds the guides that go deeper:

ChapterAnswers
Getting startedHow do I get from zero to a deployed site -- locally, on a VPS, on Cloudron, on Pages?
Writing, The archive, Running itHow do I run it day to day -- writing, deploying, cron, backup, troubleshooting?
ArchitectureHow does it work inside -- content model, build pipeline, deploy, client side, security?
DecisionsWhy is it built this way -- the trade-offs, each with its admitted cost?
ImportingHow do I bring an old blog in -- per-source guides, what gets kept, how to undo?
SkinningHow do I make it look like something else -- without forking the engine?
LocalizationHow do I translate the engine into my language?
ReleasingWhat has to be true before a tag, and what happens after it?

Where to start, by situation

Read more

What you need

From zero to a deployed site. The main README is the quick tour; this is the complete path, including the server side. Day-to-day usage lives in Writing.

In a hurry? The Quick start below is a complete copy-paste path to a site running locally on your machine -- one block per platform. The numbered sections after it are the full reference, including picking a deploy target to put the site on the internet.

Read more

Quick start

Each block below is the whole path for one platform: prerequisites, clone, config, first post, local preview. They end at the same place -- a site you can see at http://localhost:8000/ -- and from there, section 6 takes it to the internet.

./setup.sh is the config step: it asks for the settings a site needs, checks the answers as it goes, and writes config/site.yml and env.sh for you. Most questions can be skipped with Enter, which keeps what is there -- the ones that cannot are the site's title, short name, description, author and address for as long as they still hold the template's examples -- and nothing is written until you have seen the diff and confirmed it -- so it is also the way to change any of this later. If you would rather edit the files yourself, the numbered sections below are the full reference and section 2 still starts with the two cp commands; the wizard leaves both files commented and hand-editable either way.

macOS

The system Ruby is 2.6 from 2019 and Apple treats it as frozen, so the one real step is a current Ruby via Homebrew:

Read more

Configure the site -- config/site.yml

git clone https://github.com/DanielSnor/blog.sh.git myblog
cd myblog

One clone = one site. Everything personal (posts, media, config, secrets) is gitignored, so pulling engine updates later never touches your content -- see Updating the engine.

cp config/site.yml.example config/site.yml

Or let ./setup.sh do it: it seeds the file from this same example -- comments and all -- and fills in the answers you give it, which is the same file you would have written by hand, minus the chance of a tab where a space belongs. It covers the site block, the comments network and the deploy target; ./style.sh covers banner, about, footer, social, colors, fonts, widgets and nav. Both are re-runnable and neither takes anything away from editing the file directly.

Read more

Configure the environment -- env.sh

cp env.sh.example env.sh
chmod 600 env.sh

env.sh holds secrets and per-environment values; it is gitignored and mode 600 because live credentials go in it. An unedited copy is enough to try everything locally -- with no deploy target configured, uploads are skipped (logged, not an error).

The one value worth knowing about right away is SITE_BASE_URL: the canonical URL normally comes from site.base_url in config/site.yml (step 2), and this env.sh value overrides it when set -- it exists so a staging or local environment can point somewhere other than production while building from the same config. One site, one URL? Set site.base_url and leave this out.

Watch the order they start in: the example above ships SITE_BASE_URL active and pointing at https://example.com, so filling in site.base_url carefully and leaving this file alone gives a site that still calls itself example.com in its feed, its sitemap and every share preview. Either comment this line out or give it the same address. ./setup.sh writes both to the same value for exactly this reason, and ./blog.sh doctor reports the address that would actually be used rather than the one in the config.

Banner and favicon

Both ship with the engine as assets/images/defaults/ -- the first build copies whatever is missing to the live names assets/images/header.png (the path banner.src defaults to) and assets/images/favicon.png, so a fresh clone renders before you've drawn anything. The live names are gitignored: replace them with your own artwork and neither git pull nor a rebuild will touch it. That also means nothing else keeps a copy -- put both files in your backup (Backup), or a restore brings the site back wearing the engine's default artwork. Set banner.width/height to the real dimensions of your image: those attributes are what reserves space before it loads, and a mismatch makes the page jump.

./style.sh does that part for you -- give it the path to an image and it copies the file into place, reads its real dimensions and writes them, so the pair can never drift from the file. ./blog.sh doctor reports it if they ever do.

The favicon is used three ways from that one file: the <link rel="icon">, an apple-touch-icon (iOS scales it down for a home-screen bookmark), and a generated /favicon.ico for clients that request the root path without reading the link. A square PNG of 180 px or more covers all three.

Read more

First build and local preview

./blog.sh add                  # write your first post (opens $EDITOR)
ruby build/build_blog.rb       # build into public.nosync/ (one language; ./blog.sh rebuild builds all and deploys)
./blog.sh preview               # preview at http://localhost:8000 (Ctrl-C stops it)

add always creates a draft and offers publishing interactively -- see Writing for the full authoring flow.

Replacing an existing blog? Bring the old content in before you deploy, so the first published version of the site is already complete. ./import.sh walks you through it -- Bluesky, Mastodon, Pixelfed, Tumblr, a Twitter/X archive export, or WordPress and any RSS/Atom feed -- and always previews what it would write before writing anything. See The archive → Importing, and ./import.sh --help for the scriptable equivalents.

Pick a deploy target

Set DEPLOY_BACKEND in env.sh plus the values for your choice, then:

./scripts/deploy-web.sh --dry-run   # shows what would upload, touches nothing
./scripts/deploy-web.sh             # first real deploy (uploads everything once)

Every later deploy uploads only new/changed files -- a SHA-256 manifest (.deploy_manifest*.json, one per backend) tracks what the target already has, while .deploy_baseline.json records the shape of the last build the safety guards accepted. Both are gitignored and both are disposable.

The first deploy is rarely the one you run by hand. From here on the engine deploys as part of writing: saving a draft builds the site and deploys it, because a draft's preview is a real address on the real site (see Writing → Writing and publishing). So the target is written to the first time you save anything -- not the first time you type deploy-web.sh. Whatever is already at that address under a name the build also uses is replaced, and a placeholder page is exactly such a name.

Read more

Running on a server

The engine runs wherever the content lives -- typically either on your laptop (deploying to a remote target) or on the server itself (SSH in to write; this is how the reference deployment on sean.cz works, inside a Cloudron/Docker container). For the server variant:

  1. Clone the repo on the server, repeat steps 2-6 there. env.sh stays on the server only -- it never syncs anywhere.
  2. Install the widget cron (see Running it).
  3. For writing from a phone, prepare the incoming/ staging directory: any SSH/SFTP account that can write into <repo>/incoming/ works. A dedicated user without sudo, limited to SFTP, is the safe shape -- photos are uploaded there by name and referenced as bare filenames in posts (see Writing).
  4. For a post sent whole from a phone, add the key the shortcut uses to authorized_keys with a forced command pointing at scripts/receive.sh -- the line, the RSA requirement and the size ceiling are in Writing. Nothing new listens on the network; it travels over the SSH the server already has.
  5. In a container setup (Cloudron and similar), the engine lives inside the container's persistent data directory and commands run via docker exec / the platform's terminal -- the engine itself doesn't care, it's just Ruby + bash in a directory.

Comments network (optional): Mastodon or Bluesky

Every published post is announced on the configured network, and replies to that announcement are the post's comments. A reply's pictures appear under its words -- thumbnails linking out to the full image, loaded from the commenter's own network the same way their avatar already is; a reply marked sensitive keeps its pictures to itself. Configure exactly one of the two -- the build refuses a config with both, since a discussion split across two networks serves nobody. Without either section, everything else still works; publishing just skips the announcement (logged, not an error).

Mastodon:

Read more

Updating the engine

git pull
./blog.sh rebuild

rebuild, not ruby build/build_blog.rb: the build script renders one language per run, and rebuild runs it for every language the site publishes and then deploys. On a site in two languages the bare script followed by a deploy leaves the second language's pages as the previous version built them -- and uploads them that way.

main is the release branch: a clone on it gets each release by git pull. A clone that was put on a release tag (git checkout v1.8) is on no branch, and git pull refuses with "You are not currently on a branch"; move it to the next release by name instead:

git fetch --tags && git checkout v1.9

The first build after an upgrade may be a full one -- the engine's own fingerprint changed, so the record of the last build is thrown away -- and it writes .build_cache.json in the installation directory, which is per-machine, gitignored and always safe to delete. Deploy the assets with it: a page that cannot fetch assets/js/share.js logs a 404 on every load.

Read more

Writing and publishing

Day-to-day usage of an installed site: writing, deploying, cron, backup and what to do when something fails. For the zero-to-deployed path see Getting started.

./blog.sh with no arguments opens a numbered menu; every command also works directly (./blog.sh add, edit, publish, ...). The flow is built around drafts:

  1. add opens $EDITOR with a frontmatter template (title, tags, type) -- the in-editor hint links to the /markdown/ syntax reference on your own site. Saving always creates a draft, and the draft appears at a hidden /draft/<token>/<slug>/ address with noindex -- invisible in every listing, shareable by URL (that's the point: open the preview on a phone or send it to someone before publishing).
Read more

Writing a post in another language

Only on a site that publishes more than one -- site.locales in Getting started is what says so.

./blog.sh translate my-post --lang de

Or from the wizard, which is where the rest of the work happens: pick the post, and the crossroads that offers its text and its properties offers the languages too. With one other language it names it ([l] language: Deutsch); with several it opens a picker that says what is already written, what is only started, and what has nothing yet -- the same three states check --languages prints. The properties screen carries a languages row for the same reason. (As data the two say them in different words: props --json has written, started and none, check --languages --json has written, title_only and missing.)

The editor that opens holds a title and a body, and nothing else. That is the whole of what a translation may carry: the date, the tags, the series, the pin and the state belong to the POST and are true in every language at once, so they are changed with props or edit and never here. A translation cannot quietly disagree with the post it belongs to.

Read more

Writing from a phone

The trick is that a bare filename in an image line resolves against the incoming/ staging directory:

  1. Shoot a photo, upload it via any SFTP client into <repo>/incoming/ (setup: Getting started).
  2. SSH into the server, ./blog.sh add, and write ![caption](photo.jpg) -- no path.
  3. If the photo hasn't finished uploading yet, the CLI waits and re-checks on Enter instead of failing.
  4. On save the photo is copied into media.nosync/<year>/<slug>/ and its incoming/ copy is removed -- an empty incoming/ means nothing is pending.
  5. Editing that post later doesn't need the photo again: a bare filename is looked for in the post's own media.nosync/<year>/<slug>/ first and in incoming/ only after that, so a file that's already been saved resolves without any upload (and without being copied again).
Read more

Driving the engine from a program

Every screen a person reads before acting, and every action those screens offer, has a --json form: one object on standard output, nothing else on it, every key always present -- a consumer that has to test for a missing key is a consumer writing its own parser. A refusal is an object too, {"ok": false, "error": "<code>", "message": "<a sentence>"}, with a zero exit: the status answers whether an answer arrived at all, the object says what it was. (The engine falling over is the one thing that still leaves with 1 and prose on stderr.) This is the contract add --json and publish --yes --json have kept since the phone first needed them; the rest of the engine keeps it now.

Three rules follow from "nobody is at the keyboard":

Read more

Pinning a post to the front page

The [c] action in ./blog.sh props <slug> pins a published post -- or unpins it again; pinned: true in the post's header (./blog.sh edit <slug>) does the same thing the long way round. A pinned post is held at the top of the front page, marked with a pin in the corner of its date badge. It appears there once: while it is still on the front page anyway it is lifted to the top rather than shown twice, and once it has aged onto /page/2/ the front page keeps the copy at the top while page 2 lists it in its normal place, unmarked.

Only the front page. Type and tag listings, the RSS feed, the sitemap and the search index stay strictly chronological -- a pin is a statement about the front page, not about the archive. Toggling it is a small deploy, because pagination is anchored and the front page is the only flexible one. Pin a second post and the newest of them wins, with a warning in the build output.

Publishing slots

Set the times posts usually go out and [s] stops asking for a date:

publishing:
  slots:
    - "mon 09:30"
    - "wed 09:30"
    - "fri 09:30"
    # or a single "daily 09:00"

The draft dialog then names the next FREE slot in the [s] choice itself, and the prompt offers it: Enter accepts, typing a date overrides it, the cancel word backs out. Free means no other scheduled post is aimed at that exact time, so three drafts written in one evening queue onto three consecutive slots instead of publishing together, and the confirmation says which post goes out before this one.

The offer also names the earlier slots it had to walk past and the post sitting in each, so a queue that seems to skip a day explains itself. The properties dialog of a scheduled draft prints the whole queue for the same reason, with an arrow on the post you are looking at.

Read more

Attachments and the document type

A line that is nothing but [label](handbook.pdf) -- a bare filename, whitelisted extension -- makes the file part of the post: it is picked up from incoming/ exactly like a photo, stored in media.nosync/<year>/<slug>/, and rendered as a download card showing the label, the extension and the file's size. A link to an address stays a link; the engine can only publish files it was handed.

Whitelisted: .pdf .zip .tgz .epub .txt .md .ics .gpx .csv (.tar.gz is not, because only the last suffix survives the rename -- use .tgz). A post whose text is a short line plus attachments is filed under DOCUMENTS, which appears in the nav once the first such post exists; a longer article that attaches its data stays an article with a file on it.

Importing from another platform

./import.sh opens its own wizard: pick a source, and it reads the whole thing in dry-run first and tells you what would be written -- how many posts and media files, the first few slugs, and how many items it skipped and why. Nothing is written until you confirm, and confirming means typing the number of posts rather than pressing a key -- an answer you can't give without having read the preview. The sources cover blog and newsletter platforms (WordPress, Blogger, Ghost, Medium, Substack, a Jekyll/Hugo tree, ...), the social networks (Twitter/X, Mastodon, Bluesky, Instagram, ...), podcast feeds, and the Wayback Machine for a blog whose platform no longer exists at all. The wizard groups them by that question -- a blog you published, a network you posted to, a dead site -- and the full list lives in Importing.

Every source also runs without the wizard, for cron or a scripted migration -- one scripts/migrate_<source>.rb each, e.g. scripts/migrate_bluesky.rb <handle>, scripts/migrate_instagram.rb <export-dir>, scripts/migrate_wayback.rb <https://dead-blog.example>, scripts/migrate_feed.rb <export.xml | feed-url>. Those skip the preview and write immediately; see the README.

Read more

Taking your content elsewhere

./blog.sh export ~/my-blog-export        # everything, drafts included
./blog.sh export ~/public-copy --no-drafts
./blog.sh export ~/somewhere --dry-run   # counts, writes nothing

Writes the archive as a tree of markdown files with YAML front matter, in Jekyll's layout: _posts/2026-05-01-slug.md, _drafts/slug.md, pages at the root, media copied under assets/<year>/<slug>/. Without a directory it writes to tmp/export. A directory that already has something in it is refused until you repeat the command with --force, which writes alongside what is there -- an export never deletes anything, at either end. With --force, files an earlier export of this site wrote are refreshed in place; anything else -- a cloned site's own _config.yml, a post of somebody else's under the same file name, a different picture under the same path -- is left exactly as it is, the post of ours goes beside it under another name, and the summary lists what was left alone.

A post that cannot be written -- a stray byte from an old import, a slug that is not one path segment -- is named at the end and the rest of the archive still comes out; the command then exits 1, so a backup made by cron is seen to be short.

Read more

Reading the archive

Three pages the build writes for you, all from the posts themselves -- there is nothing to configure and nothing to keep up to date.

/archive/ is a map of the site in two levels. The first is a row per year with a strip of twelve months, each month shaded by how much was written in it; the second, /archive/<year>/, is one line per post, grouped by month, oldest first. No excerpts and no pictures: this is an index, and its whole point is that twenty years fit on one screen. Pagination cannot do that -- it is anchored to the oldest post, so /page/128/ says nothing about whether it holds 2009 or 2014. Every post's date badge links into the month it belongs to, so a reader who finds one post can see what surrounded it.

Under the map a legend says what the shading means -- the four steps between "less" and "more", in the map's own colours. A year's page opens with that year's row of the map again, behind the words "jump to a month": a month with posts is a link to its section further down the page, shaded as it is on the map.

A year nobody wrote in gets a row on the map and no page of its own: an empty page is an invitation to a dead end. A post is filed under the year of its ADDRESS, so /archive/2025/ and /posts/2025/ always agree.

Read more

Giving a tag its own icon

A tag can carry an icon, and it shows up in two places: the heading of its /tag/<name>/ listing, and the date badge of every post that has the tag, where it replaces the content-type icon rather than joining it.

tag_icons:
  - tag: "fotografie"
    icon: "image"
  - tag: "kolo"
    icon_svg: '<svg viewBox="0 0 24 24" ...>...</svg>'

A tag is matched here by its address -- the /tag/<name>/ its listing lives at -- so how the name is spelled, here or in a post, makes no difference: sci-fi, Sci Fi and sci_fi are one tag, share one page and wear one icon. A tag that leaves nothing to be addressed by, an emoji or a piece of punctuation, has no page and takes no icon either.

The order is the priority. Most posts carry more than one tag -- 68% of them on the archive this engine was built around -- and the tag a post was given FIRST is usually the one an importer added, not a subject: twitter opens 1256 posts there. So the first entry in this list that a post has is the one it wears, and the site owner decides once instead of post by post.

Read more

Emptying the trash and the versions

The engine keeps two things after you are done with them, and both have a way back: a deleted post waits in trash/ for ./blog.sh restore, and every save keeps the previous text of a post, offered by the version picker in ./blog.sh props. Neither had a way OUT before 1.6 -- the only way to empty either was rm on the server.

./blog.sh empty trash       # deletes every trashed post for good
./blog.sh empty versions    # keeps each post's newest version, removes the older ones

Both say how much they are about to delete and confirm by having you type that number -- the way delete has you type the slug. A trash has no slug to repeat back, and the count is the one number you have just been shown, so typing it means having read it. Anything else, including an empty answer, cancels and deletes nothing.

empty trash takes the media of a trashed post with it, and covers both shapes the trash has had: today's trash/<year>/<slug>/ and the flat trash/<slug>/ of an installation older than that.

Read more

Checking the archive

./blog.sh check            # walks every post and every media file
./blog.sh check --online   # also asks whether the links that leave the site still answer
./blog.sh check --json     # the findings as data: every one of them, no screen

Both answer as data when asked: doctor --json and check --json print one object each, every finding in it, for a program rather than a reader -- and check --languages --json prints the table of languages the same way.

doctor asks whether the installation is sound and takes a second; this asks whether the archive is, and walks all of it -- two commands so the fast half keeps being run, and it is the fast half that belongs before a deploy. check reads the content on disk, so it works before a build has ever run, and it names a post and a slug for every finding rather than a file under public.nosync: something to go and fix.

The one thing it looks at outside the archive is config/site.yml, and for the same reason: this is the command people run before a build, and a config the build will refuse is not something to find out afterwards. It reports that file in doctor's words, so the two never describe it differently.

What it looks for, each with a line saying what to do about it:

Read more

Counting the archive

./blog.sh stats           # a screen: posts by year and kind, words, tags, media, sources
./blog.sh stats --json    # the same figures, for whatever reads them next

Reads the archive on disk -- no build, no network, no env.sh -- and comes back in seconds even on a large archive. What it says, and why each line is there:

  • The archive: posts split into published, drafts, scheduled and pages. The four add up to the total; a page that is also a draft counts once, as a page.
  • By year and What they are: where the archive is thickest, and what it is made of (the dominant content type per post, the same one the /type/ listings use).
  • Words: total, mean and median. Both averages, deliberately -- an archive half made of imported tweets pulls the mean far above the middle, and only the pair says so.
  • Tags, Media (files on disk, or what the posts ask for when the media directory is not here) and Where it came from -- the platforms the archive is actually made of, which is a different number from how many the importers support.

--json prints the same figures unlocalized and unrounded, so the numbers can go into a post, a cron job or a graph.