Tagoperations22

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.

On this day

./blog.sh on-this-day                    # today: every post from earlier years, and what the card shows
./blog.sh on-this-day --date 12-24       # another day of this year
./blog.sh on-this-day --date 2030-12-24  # ...or of another year, ages counted from then
./blog.sh on-this-day --json             # the same, as data

The same selection feeds a sidebar card, switched on by its name alone:

widgets:
  on_this_day:
    # heading: "On this day"    # optional -- the engine says it in every language

(or in ./style.sh, under the sidebar). The card reads the site's own archive, so there is no account to give and nothing to fetch. What it shows, and why each rule is there:

Read more

Letting a reader pass a post on

The old way to do this was a row of buttons for Facebook, Twitter and Pinterest, each with one address to send somebody to. The fediverse has no such address: the destination is the READER's instance, and a page has no way to know it.

So the block is a list you write, and it holds three different kinds of thing:

share:
  - mastodon
  - bluesky
  - email
  - copy
  - system

Leave share: out and no block appears. The order here is the order they are drawn in.

Read more

Rebuilding only what changed

A publish rebuilds the site, and until 1.6 that meant rebuilding all of it: every page rendered, and every file read back off disk to find out whether its bytes had moved. On an archive of a few thousand posts that is most of a minute, every time, for a change that touched a dozen files.

Since 1.6 the build writes down what it produced -- in .build_cache.json in the installation directory -- and the next build leaves alone the pages whose inputs have not moved. Nothing you do changes: ./blog.sh publish and ./blog.sh rebuild work exactly as before, only quicker. The summary at the end says how many pages were left alone, so you can see it working.

Measured on this project's own archive of 6,639 posts, on the server it lives on: a rebuild that changes nothing now costs a seventh of what it did, and an ordinary publish about two fifths. Backdating is the case it can do least about, and the measurement says so plainly -- a post dated into the early 2000s still costs four fifths of the old build. A post arriving in the middle of the archive shifts every listing page between the front page and where it lands, so those pages really did change and really do have to be built. Importing an archive, which is backdating several thousand times over, is a full build for the same reason.

Read more

A published picture is the archive's own file

Until 1.6 every picture existed twice: once in media.nosync/, and once as a copy of itself under public.nosync/. On this project's own installation that was 1.8 GB counted twice, and every import doubled again.

They are one file under two names now. Nothing about publishing changes, and neither does the archive's copy: deleting a picture from public.nosync/ -- by hand, or by the sweep, or by a deploy with --prune -- drops that name and leaves the archive's. What DOES follow the link is the mode: making the published picture readable makes the original readable too, which is the direction that was wanted, because a picture the web server cannot read was the bug this makes impossible.

It saves disk and it does not save backups. Measured against this project's own: 200 files under two names take 101 MB on the disk and 201 MB in the backup, because the backup copies file by file and stores each name in full. Back up media.nosync/ as you always did -- that is the archive's copy, and it is the one that matters.

Deploying

./blog.sh rebuild = build + deploy with --prune in one step; the publish/edit flows run it for you. The deploy script alone:

./scripts/deploy-web.sh             # only new/changed files
./scripts/deploy-web.sh --dry-run   # print what would happen, touch nothing
./scripts/deploy-web.sh --prune     # also delete files the build no longer generates
./scripts/deploy-web.sh --force     # ignore the manifest, re-upload everything
./scripts/deploy-web.sh --only=A,B  # just the listed files

Things worth knowing:

  • The safety guards. Four of them, all measuring this build against the last build a deploy accepted -- recorded in .deploy_baseline.json before the first byte goes out, so no upload failure can move it:
GuardTrips atWhat it does
File count dropped>20%, at least 8 filesstops
Total bytes dropped>50%, at least 25 MBstops
File count grew>20%, at least 25 filesstops
Total bytes grew>50%says so, continues
Read more

Cron (sidebar widgets and post stats)

scripts/refresh-sidebar.sh refetches the widget JSON (toots, Bluesky, Pixelfed, commits, RSS) and per-post stats, then uploads only those files -- no site rebuild:

*/30 * * * * /path/to/blog.sh/scripts/refresh-sidebar.sh

Every 30 minutes is plenty. The crontab lines in this section are recommendations; what actually runs is whatever the installation's own crontab says -- installing it is step 7 of Getting started. Post stats refresh live for posts younger than ~90 days; older posts get a full refresh about once a week (tracked in .stats_full_refresh_at). A failed fetch keeps the last known content rather than publishing an empty widget -- a one-minute network hiccup never blanks the sidebar. Systems without cron: a systemd timer or launchd job invoking the same script does the same thing. No widgets configured = no cron needed.

The on_this_day card (see On this day) fetches nothing, but its day turns over only here: its file is rewritten when the date changes and left alone on every other tick, in the site root and in every language tree.

Read more

Backup

Back up the per-deployment data -- the engine itself is a git clone and everything generated is rebuildable:

PathWhy
content.nosync/posts/the posts -- the one thing that's truly irreplaceable
content.nosync/versions/what each edited post said before its last ten saves, which is what [v] restores from (Properties and actions). It sits beside the content and dies with it, so a backup of the posts alone keeps the archive and loses the undo
media.nosync/their images and videos. A published picture has been a hardlink to this file since 1.6, so a backup still stores it once per name -- this is the copy that matters (A published picture is the archive's own file)
config/site.ymlsite identity and integrations
config/site.<lang>.ymlwhat the site says about itself in each further language: its title, menu, the words for its tags and series, and where a language without a locale borrows its interface. Gitignored like site.yml, so a fresh clone has none -- and a site that names ui_language in one does not build without it
assets/images/header.png, assets/images/favicon.pngyour banner and icon -- gitignored, so a fresh clone brings back the engine's defaults instead, silently (Banner and favicon)
env.shtokens (or re-create them; mind the file's 600 mode in backups too)
trash/optional -- deleted-but-recoverable posts
config/palettes.ymlonly if you added a palette of your own -- the file itself ships with the engine
Read more

Troubleshooting

SymptomCause and fix
Anything at all, and you need to know what you're running./blog.sh version -- it needs neither env.sh nor a config, on purpose.
The site looks wrong, or you want to change how it looks./style.sh -- a menu over everything that decides how the site looks and what it says about itself; it lists its own sections. The bio, the footer note, the copyright line and the banner's claim are Markdown, the same as a post, and raw HTML still works in them; a multi-paragraph value needs YAML's literal block (`-) -- the notes in config/site.yml.example` show why. Picking a palette offers a preview (light and dark side by side, uploaded to the site too so a phone can see it) before anything is written -- temporary on purpose, the next build takes it down -- and the run ends by offering a rebuild.
Anything config-shaped, and you want the whole picture./blog.sh doctor -- it reads whatever is on disk and reports every problem at once, each with a fix line. It runs on a config too broken for anything else to load, including one whose YAML won't parse, and needs neither env.sh nor a valid config. Add --online to also ask whether the feeds, the analytics script and the access token still answer, and whether the deploy target does -- with what stands in its root that the site did not put there, and --strip-location to clean the location out of photos saved before the engine did it on the way in (Writing from a phone); rebuild afterwards, since the site is built from what it cleans.
Anything archive-shaped -- a hole where a picture should be, a link that leads nowhere./blog.sh check -- it walks every post and every media file and says what is broken inside the archive, each finding with a fix line; --online additionally asks whether the links that leave the site still answer (Checking the archive). It only ever reports: nothing is deleted or rewritten, so it is safe to run at any time, including from cron.
config/site.yml is not valid YAMLThe message names the line and column. Almost always a tab where spaces belong, a missing quote, or a colon inside an unquoted value (title: Colon: here). ./blog.sh doctor says the same thing without stopping at the first problem.
A save aborted and took your text with itIt didn't: the text is in .last-edit.md, and the next add/edit offers it back -- [r] opens the editor on it, [d] throws it away, [c] leaves it alone. Text from an interrupted edit <slug> is only offered to that same post: restoring it into an add would make a second post out of it, so the offer names the command that does continue it.
Missing env.sh./setup.sh, or copy the template by hand: cp env.sh.example env.sh && chmod 600 env.sh. An unedited copy works locally.
Missing config/site.ymlSame two ways: ./setup.sh, or cp config/site.yml.example config/site.yml and fill it in -- the build refuses to guess.
Duplicate year/slug ... build stoppedTwo posts resolve to the same URL and media directory. Rename one slug; the build aborts rather than silently overwriting one with the other.
Deploy stopped with a "% drop/increase" messageOne of the four guards (Deploying) -- broken build until proven otherwise, --force only when the change is intended.
Deploy or save stopped naming an oversized fileThe one file-size limit (Deploying) -- shrink the file, or take it out of the post and link to it instead.
N deploys in a row have not finishedSomething is refused every time; the failures listed under that line say which (Deploying).
Surfer ... refused the access token (HTTP 401)Surfer 7 no longer takes access tokens. Create an app password in your Cloudron profile (Profile -> App Passwords) and set SURFER_USERNAME and SURFER_PASSWORD in env.sh (Getting started).
Surfer ... refused the username and password (HTTP 401)SURFER_USERNAME is the Cloudron username, not the e-mail, and SURFER_PASSWORD an app password, not the Cloudron sign-in password. A refused sign-in stops the deploy at the first file, and ./scripts/deploy-web.sh then exits with 4 rather than 1, so a script can tell "fix env.sh" from "try again".
Mastodon API returned 401 / toot was not createdMASTODON_ACCESS_TOKEN missing, expired, or lacking the write:statuses scope. The post itself is fine -- fix the token and use ./blog.sh toot <slug>.
Posting to Bluesky failed / announcement not sentBLUESKY_APP_PASSWORD missing, revoked, or it's the account password instead of an app password (Settings → Privacy and security → App Passwords). The post itself is fine -- fix it and use ./blog.sh bluesky <slug>.
Every comment disappeared after turning on comments.approvalExpected until you star them -- moderation publishes only favourited replies (Cron). If starring changes nothing either, ./blog.sh doctor --online catches the usual cause: a token without read:statuses.
A comment you starred still isn't on the siteIt arrives with the next refresh-sidebar.sh run; an older post waits for the full pass, which --full does now (Cron). Check the cron job is actually installed.
comments.approval is on but MASTODON_ACCESS_TOKEN is not setThe cron run refused to publish rather than emptying every thread; the pages keep their last known comments until the token is there (Cron). Same for BLUESKY_APP_PASSWORD on a Bluesky site.
Sidebar widget disappeared from the pageIts fetch returned nothing repeatedly (refresh-sidebar logs say which) -- the widget card hides when its JSON is empty/unreachable. Check the instance/feed URL in config/site.yml.
Read more