No description https://arch-linux.cz/
  • Ruby 64.3%
  • CSS 31.9%
  • JavaScript 1.8%
  • Shell 1.6%
  • HTML 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-03 16:01:50 +01:00
.gitea/workflows CI: bez sudo apt-get, nástroje jsou na runneru předinstalované 2026-11-03 16:01:50 +01:00
assets engine update 1.10 2026-10-20 21:16:23 +02:00
build data-photos counts the pictures a post shows: one an image block 2026-10-17 09:13:34 +02:00
config engine update 1.10 2026-10-20 21:16:23 +02:00
content.nosync moderni-cli-uvod: odstraněn neplatný type 2026-10-20 21:18:52 +02:00
docs The wizards set what only site.yml could 2026-10-17 14:07:04 +02:00
lib A link with no text keeps its address (#65) 2026-10-17 00:08:33 +02:00
locales The wizards set what only site.yml could 2026-10-17 14:07:04 +02:00
media.nosync moderni-cli: kompletní setup + fzf (6/6) 2026-09-25 16:07:03 +02:00
scripts The wizards set what only site.yml could 2026-10-17 14:07:04 +02:00
skins A gallery's card hands its next pictures to a skin: Pictorial's mosaic 2026-10-17 01:41:58 +02:00
templates A script's address carries the fingerprint of its bytes too 2026-10-17 08:48:52 +02:00
.gitignore engine update 1.10 2026-10-20 21:16:23 +02:00
blog.sh The docs before the 1.10 tag: install.md names site.author_name, the On this day card and layout.lead_card, points a server's step at the app's door and its pairing, moves its tag example to v1.10 and says that going back to 1.9 cuts the apps off; README says how a phone writes in 1.10 and that /write/ leaves in 1.11, with the launcher, the card and remote.sh; the changelog's lang sentence includes pair; operations.md gives version --json its ok and max_mb, the queue its two words, the lang: header its paragraph and the sidebar heartbeat its line under backups; skinning.md says where mentions are marked and how a moderated answer is indented 2026-10-14 21:12:40 +02:00
CHANGELOG.md The wizards set what only site.yml could 2026-10-17 14:07:04 +02:00
env.sh.example Check and the build agree about a translation's old addresses, tree by tree; check --repair rewrites a dead link into a language's tree instead of redirecting into it; pair reads env.sh, as doctor does 2026-10-09 20:10:19 +02:00
import.sh The deferred list, now that there is time for it 2026-08-07 23:12:44 +02:00
LICENSE Initial release of blog.sh 2026-07-29 11:02:29 +02:00
NOTICE Five things off the backlog: the cache, the card, the phone, the icons, the video 2026-09-03 12:50:19 +02:00
README.md style.sh asks what 1.11 can be told: the skin, the lead card, a series 2026-10-17 01:00:31 +02:00
setup.sh The launcher on a Mac opens one window: a Terminal that had to start runs blog.sh in the window it opened itself; the question macOS asks says what the launcher does, in the site's language; and ./setup.sh --launcher no longer wipes the terminal first 2026-10-10 16:57:37 +02:00
style.sh A post handed over as a file 2026-08-31 14:59:47 +02:00

blog.sh — .sh → .rb → .html

sh → .rb → .html

minimalistic static web/log cms

License: MIT Shell Ruby JSON HTML CSS Mastodon Bluesky

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.sh asks 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
Homepage, light mode Homepage, dark mode

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.sh writes, edits, publishes, schedules and deploys. A phone writes in an app over the SSH the server already has.
  • 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.sh bare 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; --json answers as one object. On a Mac, ./setup.sh --launcher puts an icon in Applications that opens it. → operations.md, install.md
  • From a phone. Native apps for iOS and Android, in preparation, reach the engine over the SSH the server already has, held to one script (scripts/remote.sh) that runs one engine command per request and nothing else. An app is let in by a code that ./blog.sh pair draws in the terminal -- no key or address typed anywhere. The /write/ page and its shortcut were taken out in 1.11. → operations.md, 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, -word to 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, /tag/ lists every tag with its count and /series/ every series with its parts. → architecture.md, operations.md
  • More than one language. A post keeps one set of metadata and a text per language; each language gets a root of its own (/de/) with its own addresses, feed and listings, the site's own language keeps the root, and a post nobody translated is shown with a link to the one copy it has. The site's own words -- title, about, footer, menu, tags -- are translated in config/site.<lang>.yml; the engine's words already are, in English, Czech and German. → localization.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: fav publishes 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 -- and the archive's own posts from this day in earlier years. → operations.md, operations.md
  • Appearance. Light and dark from CSS custom properties, a three-state toggle, and a seven-key palette in config/site.yml that 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.sh out 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 another locales/<code>.yml for 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, skin and layout, 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/
                           remote.sh          the forced command for an app's key: one engine command (remote.rb) or one delivery per connection
                           pair.rb            ./blog.sh pair: lets an app in by a code it reads (enroll.sh takes its key)
                           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/)
skins/                   The skins a site can wear (site.skin; classic unless it says otherwise)
config/site.yml.example  Documented config template -- copy to config/site.yml (gitignored) per deployment
config/site.lang.yml.example  One more published language, described -- copy to config/site.<lang>.yml
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

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 if gem install rexml is 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 through rexml
  • bash (the thin blog.sh / deploy-web.sh / refresh-sidebar.sh wrappers)
  • 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, skin, 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>] [--json]
                               # without a slug, offers the last 50 posts; --json hands the text out as data
./blog.sh translate <slug> --lang <code> [--json]
                               # writes the post's text in another language the site publishes;
                               # --json hands the text out as data
./blog.sh props [<slug>] [--json]
                               # 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;
                               # --json prints it all as data, with the actions that apply, and takes
                               # the screen's keys as flags: --set k=v, --drop-address [--kind], --rename --yes,
                               # --versions, --restore-version --yes, --rebuild
./blog.sh publish [<slug>] [--yes] [--no-announce] [--allow-partial] [--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;
                               # --allow-partial lets it out without the words of every language the site publishes
./blog.sh schedule [<slug>] [--allow-partial] [--at <time>] [--cancel] [--compact] [--json]
                               # asks for a date, then auto-publishes the draft when it arrives;
                               # --json with --at writes that date, --cancel takes the post out of the queue
./blog.sh queue [--json] [--up <slug>] [--down <slug>] [--move <slug> --to <n>]
                               # what is scheduled, when each one goes, and the slots it uses
./blog.sh unpublish [<slug>] [--yes] [--json]
                               # moves a published post back to draft (also deletes its announcement);
                               # --yes belongs to --json: at the keyboard it asks
./blog.sh delete [<slug>] [--yes] [--rebuild] [--json]
                               # deletes a post to trash/ (--yes belongs to --json here too)
./blog.sh restore [<slug>] [--rebuild] [--json]
                               # restores a post from trash; --json without a slug lists the trash
./blog.sh empty trash [--yes] [--json]
                               # deletes everything in the trash, for good
./blog.sh empty versions [--yes] [--json]
                               # keeps each post's newest version, removes the older ones
./blog.sh toot [<slug>] [--force] [--json]
                               # (re-)sends the comment toot (Mastodon sites)
./blog.sh bluesky [<slug>] [--force] [--json]
                               # (re-)sends the announcement (Bluesky sites)
./blog.sh rebuild [--full] [--force] [--json]
                               # rebuilds and deploys the whole site, every language it publishes;
                               # --full builds every page again instead of only the changed ones;
                               # --force lets the deploy past its guards and uploads everything
./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] [--search="words"] [--json]
                               # the same, printed one line per post; --json as data
./blog.sh doctor [--online] [--json]
                               # reads the configuration and says what is wrong with it;
                               # --online also asks the feeds, the analytics script, the access
                               # token and the deploy target -- whether it answers, what is in its root;
                               # --json prints the findings as data instead of a screenful
./blog.sh pair [--list] [--revoke [<device>]] [--json] [--host <address>] [--port <n>] [--no-wait]
                               # shows a code an app reads to connect, with no key or address typed;
                               # --list names the devices let in this way, --revoke takes one out
                               # (by its number, name or fingerprint, or asked which);
                               # --list --json and --revoke --json are what an app asks through its key:
                               # the list, and its own way out
./blog.sh doctor --strip-location
                               # removes the place of capture from photos already in the archive
./blog.sh check [--online] [--json] [--repair] [--languages]
                               # 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;
                               # --languages shows instead which post is written in which language
./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 drafts [--json] [<slug>]
                               # the drafts; --json with each one's text, as the phone opens it
./blog.sh on-this-day [--date MM-DD] [--json]
                               # what this day holds from earlier years, and what the on_this_day card shows of it
./blog.sh version [--json]     # 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 BLUESKY_APP_PASSWORD=...    # Bluesky announcements (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_USERNAME=...         # Cloudron username + app password (Surfer 7)
export SURFER_PASSWORD=...
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
Facebook 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
Instagram 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

./blog.sh rebuild                  # builds every language into public.nosync/, then deploys
./scripts/deploy-web.sh            # deploys only: uploads new/changed files (SHA256 manifest)
./scripts/deploy-web.sh --prune    # also deletes orphaned files on the target

ruby build/build_blog.rb builds without deploying -- one language per run (the site's own, or the one BLOG_SH_LANG names), so on a site with more than one, ./blog.sh rebuild is the way to build them all.

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. With the on_this_day card it also turns the day over: that card's file changes once a day, and this is the job that runs after midnight. 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.

pavelchcepsat.cz is a personal blog in Czech, and the first to be written from an Android phone: the /write/ page of its day sent its text from Chrome and Edge because its owner tested what they refuse.

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.