./blog.sh

./blog.sh — minimalistický blogovací systém

./blog.sh je blogovací systém, který se spouští z terminálu. Příspěvky jsou soubory, web je výsledek sestavení a komentáře žijí na Fediversu — v celé té větě nikde není databáze.

Tři věci ho odlišují od ostatních pěti set generátorů statických stránek:

  • Žádná databáze. Jeden příspěvek je jeden soubor JSON. Záloha je tar, historie je git, odchod je smyčka for.
  • Žádné závislosti. Standardní knihovna Ruby a bash. Žádné gemy, žádné npm, žádný lockfile. Stačí naklonovat a spustit.
  • Žádný systém komentářů. Každý příspěvek se ohlásí na Mastodonu nebo Bluesky; odpovědi na to oznámení jsou komentáře, které tvůj prohlížeč načítá z veřejného API.

Takhle to vypadá:

Číst dál

Jak jsem si na sdíleném hostingu nastavil blog.sh

Pavel přestěhoval WordPressový blog ze sdíleného hostingu na blog.sh, a to přes obyčejné FTP. Postup popisuje krok za krokem: export WXR, import, rclone, zkušební běh, nasazení.

Stojí za přečtení, pokud tvůj web běží na hostingu, který nabízí FTP a nic víc.

#blogsh #wordpress #staticsite

Co se načte poslední, vyhrává

Dva skutečné weby nosí vzhled, který tento engine nikdy nedodával. sh.cynicky.blog je oblečený jako Ghost — úvodní karta přes celou šířku, tečkovaný rámeček, dlaždice místo prostého seznamu. arch-linux.cz je oblečený jako téma pro Hugo, konkrétně Blowfish — kompaktní lišta, blok s profilem, karty rozložené jako úplně jiná domovská stránka. Ani jeden nevytvořil fork enginu. Ani jeden neupravil šablonu. Oba dál přijímají obyčejný git pull.

Jeden stylesheet, načtený jako poslední

site.extra_css v config/site.yml udává cestu nebo seznam cest, které se načtou po vlastních colors.css a site.css enginu. To je celý mechanismus — skin je jeden tvůj soubor, který překresluje, co už na stránce je, a engine do něj nikdy nezapisuje, takže s ním aktualizace nikdy nemůže být v konfliktu.

Jen místní cesty: každá stránka nese style-src 'self' a stylesheet z jiného hostitele by prohlížeč prostě zahodil bez jediné chyby, kterou by kdokoli uviděl — sestavení proto vzdálenou cestu odmítne nahlas, dřív než to ticho nastane.

Číst dál

./blog.sh 1.8

./blog.sh 1.8 je venku — vydání na GitHubu.

Vydání o tom, co se stane, když se něco pokazí. Každá stránka, feed a index se teď zapisuje do dočasného souboru a přejmenuje na místo, takže uložení přerušené plným diskem nebo sestavení zastavené uprostřed zápisu už nezanechá stránku v poloviční délce ani příspěvek o 0 bajtech, u kterého je starý text pryč. Studené sestavení tím zdraží zhruba o pětinu; sestavení z cache ne, takže běžný den publikování stojí přesně tolik co dřív.

Dvojí doručení už neznamená, že se něco stane dvakrát: příspěvek odeslaný z telefonu dvakrát nebo jedna potvrzenka doručená ve dvou kopiích najednou je teď jeden příspěvek a opětovný import exportu vlastního enginu už z 1200 příspěvků nedělá 2400. A adrese se už nevěří jen proto, že jako adresa vypadá — ../ ve slugu, v názvu mediálního souboru nebo v cíli přesměrování zůstane uvnitř webu, lokální nasazení zůstane uvnitř svého adresáře a přesměrování z feedu nemůže poslat stahování na localhost.

Číst dál

Sestavení neví, kam míří

Někdo postaví celý web na notebooku, naimportuje do něj desetiletí starých příspěvků, prohlédne si každou stránku — a nic z toho ještě nikam nedorazilo. V env.sh není řečeno kam a nic dalšího na to nečeká: sestavení proběhne, archiv projde kontrolou, každá stránka se vykreslí na localhost. Sestavení neví, kde má web nakonec skončit, a vědět to nepotřebuje.

Totéž sestavení může začít na notebooku, o rok později se přestěhovat na pronajatý VPS a potom skončit na instanci Cloudronu. Mezi těmito přesuny se v obsahové pipeline nemění nic. Mění se odpověď na jedinou otázku — kam mají soubory jít — a odpovědět na ni může šest věcí.

Co o tom rozhoduje

DEPLOY_BACKEND v env.sh určuje cíl; hodnoty pod ním závisejí na tom, který to je. Všechno za tímto řádkem je u všech šesti stejné. Každý backend si vede vlastní manifest — SHA-256, velikost a čas změny každého souboru, který už nahrál — takže nasazení posílá vždy jen to, co se od minulého změnilo. deploy-web.sh --dry-run tento manifest přečte, aniž by se čehokoli dotkl.

Takhle vypadá výstup, spuštěný právě teď proti vlastnímu backendu tohoto webu:

Číst dál

One line of colour

A reader changed one border colour in site.css, ran a rebuild, and waited while the build produced, byte for byte, every page that was already there. He asked why. The answer was one word in one list, and 1.7 takes it out.

What a build writes down

Since 1.6 the build keeps a record of what went into each page it wrote -- the post, the posts around it, the labels, the numbers a page counts. On the next build a page whose record has not moved is not rendered at all. Not rendered and then found identical: not rendered. The summary at the end says how many pages were left alone, the first number to look at when a build is slow.

The record holds only while the engine that wrote it is the same engine, so it hangs off a fingerprint of what the engine is made of -- the files under templates/, lib/, build/, locales/ and config/, hashed by contents rather than by timestamp. Change any of it and the record is discarded entire, because any of it can change every page.

Why the colour cost the archive

Číst dál

The last thing a recorder writes

Someone opens a post with a video in it and presses play. The connection is fine. The video is not large. Nothing happens for a while, and then the video starts all at once, as if it had been waiting for permission.

It had been waiting for the index.

Why it sits at the end

An MP4 is a handful of boxes laid end to end. One of them, mdat, is the picture and the sound. Another, moov, is the index: which frame is at which byte, how long the thing runs, what codec to hand it to. A player needs moov before it can show a single frame.

Číst dál

A bicycle nobody had to draw

The example configuration this engine ships carries a bicycle: a tag called kolo, and under it an <svg> written out by hand. It is there because there was nothing else to put there. tag_icons took the name of an icon the engine ships or a drawing of your own, and the ones it shipped were the eight content types — text, image, video, link, audio, quote, document, chat. A tag about cycling could wear the glyph for "audio", or somebody could spend an evening with a coordinate grid.

1.7 puts fifty-five drawings in between, and gives each of them a name.

Fifty-five nouns

Counted in lib/icons.rb: the eight content types, fifty-five more, sixty-three names a tag may ask for. Grouped by what a blog turns out to be about.

Číst dál

The address a post is about

Somebody sends you an article. You read it, and you have two hundred words to say about it -- not a review, the thing you noticed and one sentence about why. The post is mostly the other page, and the address it is about has to live somewhere.

Until this release the honest answer was: in a paragraph, like any other link. The card such a post opens with existed as a block -- the importers make them, out of Tumblr and out of Bluesky -- but nothing anybody could type produced one. The writer that turns a post back into markdown had no syntax for it, so edit on a link post offered to drop the card, and add <file> could not make one at all. This project's own release posts stopped being link posts after 1.3 for that reason. The tool had changed; the intent had not.

Three lines in the header

Číst dál

What a post is, not what it says

Five parts of a series written over a spring, and the third went out without its series: line. The site did what it was told. Part three sat at its date and nowhere else: off the series listing, out of the links from one part to the next, and out of the count the other four wear: part four's page called itself part 3 of 4, standing in the slot the missing post should have had. The fix was one word.

Getting to that word meant opening the article.

That is what edit does. It reads the post out of its file, writes the whole thing back out as markdown, opens it in an editor, and parses what comes back. For a post that is paragraphs and photographs the round trip is faithful and nothing is at stake. For a post carrying something markdown has no form for, it is not.

Seven rows

props <slug> gains [e] -- on a draft, on a scheduled post and on a published one alike.

Číst dál

The post that waited for a terminal

The post is written on the tram: a title, four paragraphs, two photographs with their descriptions. The switch above Send says Draft, which is what it says unless it is moved. Send, and the answer card comes back with the preview address. Then it sits. Putting a draft out was a command, and a command wants a terminal, and the tram has none.

1.6 asked for that decision at the wrong end: publish at the moment of sending, before the blog has rendered anything, or see the blog's own page and then find a keyboard. 1.7 closes that, and then closes the road the answer takes back.

One file, down the same connection

The answer card for a draft now carries a Publish button. Pressing it sends one file, called publish.txt, holding the slug and nothing else -- through the same share sheet, the same two shortcuts, the same SSH connection the post itself took.

Číst dál

./blog.sh 1.7

./blog.sh 1.7 is out. The page that writes a post on a phone now publishes it too, and it finds out what happened rather than being told: it mints a receipt before sending and asks the site for it every three seconds until the answer comes.

Beside it, the other half of the same idea — changing what a post is without opening what it says: its series, its tags, its type and three flags, from a screen. Then a link card written in the front matter, about fifty drawings a tag can wear, a video repacked on the way in so the reader does not wait for the whole file, and seventeen fixes.

#blogsh #ruby #staticsite

Everything else in 1.6

Five posts have covered the things 1.6 is about. This one is the rest, in the order an operator meets them.

A way out of the trash and the versions

Both stores had a way back — restore, and the version picker in the post's properties — and no way out, so both grew for years and nothing said by how much. empty trash and empty versions each print what they are about to remove, in items and in megabytes, and require that count typed back before anything goes. empty versions keeps each post's newest version, because versions exist to answer "give me back what I just overwrote". doctor now notes a trash with posts in it, as a note rather than a fault: on the installation this engine was built around, the only way to see it was du on the server.

A photograph stored once

public.nosync/ used to hold a second copy of every picture. It holds a hardlink now, so the pictures take half of what they did — 1.8 GB on this project's installation. It saves nothing in a backup, and that was measured rather than assumed: 200 files under two names, 101 MB on disk, 201 MB in the backup. Nothing to do; the first build after the upgrade makes the links.

Číst dál

A badge that says what a post is about

Every post wears a date badge with an icon in it, and the icon says what kind of post it is: text, a picture, a video, a quote. On a blog where nearly everything is text, that icon is the same on nearly every card. Somebody running this engine put it plainly in issue #45: one sees the generic icon so often that it becomes invisible, or clutter.

1.6 lets a tag carry an icon instead.

tag_icons:
  - tag: "build"
    icon_svg: '<svg viewBox="0 0 24 24" …>…</svg>'
  - tag: "comments"
    icon: "chat"

It replaces rather than adds

On a post's date badge the tag's icon takes the place of the content-type icon; in the heading of /tag/<name>/ it takes the place of the generic tag icon. That was the requester's first point, and the one worth asking about rather than guessing: two icons side by side on a 60-pixel badge is not a badge, it is a puzzle. A tag with no entry changes nothing, so the generic icon is still there for everything you have not named.

Číst dál

Where a reader carries a post

A post gets read, and then it goes somewhere: into a toot, a message, an email to one person. Until now the reader did that by copying the address out of the bar. 1.6 puts a row of controls under the post for it — off unless share: names what you want, drawn in the order you name it.

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

What is prefilled, and what is not

The post's name and its address, where the target takes both. Facebook and LinkedIn take the address alone and read the name off the page. The reader writes the part that is theirs. bluesky, email, facebook, linkedin, threads and x are plain links, one address each, no script behind them.

Three that are not links

Číst dál

Written on a train

A reader who was thinking about moving to this engine wrote to say he would stay where he was. He manages his blog from a phone, on the road, and a blog that can only be worked from a terminal was no use to him however fast it built. He was right.

1.6 answers him with two things, and with one thing it refuses to do.

A page on the blog itself

Set write: true and the build publishes a small editor at /write/: a title, the text, tags, photographs and video, each picture with its description. It wears the blog it writes to — its name, its palette, its language — and it offers the blog's own tags as you type, the ones used in the last twelve months first, so a tag is tapped rather than spelt a second way. A preview shows the post in the blog's own stylesheets. A row of marks above the text wraps what is selected and comes off again on a second tap. Above the send button, a Draft | Publish switch.

Číst dál

A post handed over as a file

Until 1.6 a post came from a person at a terminal. The wizard asked its questions, opened an editor, asked what to do at the end. Nothing else could write one: not a script, not a cron job, not a phone.

./blog.sh add post.md

does the same work with the markdown handed over instead of typed. No editor, no questions, no dialog at the end. A bare filename is looked for in incoming/, so the file can arrive by the same upload as the photographs, and it is deleted once the post is written.

Where the wizard would ask, this refuses

A person can be asked. A script cannot, so every question becomes a refusal that writes nothing: a photo that has not finished uploading, an empty body, a file that is not text, a second filename where one was expected. The alternative — guessing — was measured on the old code, where a byte-order mark in front of the header was taken for body text: three invisible bytes from a Windows editor and the post arrived with no title and no tags, named after the words "title:" and "tags:", exit 0. 1.6 reads the header through them.

--json: one object, nothing else

Číst dál

The time between deciding and the site saying so

Publishing a post used to cost the size of the archive, not the size of the change. A post dated today alters a dozen files. The build rendered every page in the archive and read every one back off disk to find that out.

On the archive this engine was built around — 6,639 posts, on the server it lives on — that was 47 seconds for a rebuild that changed nothing, and 48 from pressing publish to the post being out. Fine for one post in the morning. Less fine for fixing a typo in ten of them.

What the build remembers now

1.6 keeps a record of what went into each page: the post, the template, the locale, the configuration, the engine itself. On the next build it renders only the pages whose record no longer matches, and the rest cost one stat each instead of a full render and a read-back.

The same archive, measured the same way: a rebuild that changes nothing takes a seventh of what it did. Publishing a post dated today takes about two fifths — 16 seconds of build and 2 of upload, where it was 48 — and the build says so itself: Pages already up to date, not rebuilt: 10,160. The ratio is what travels; the seconds are one machine's.

Číst dál

./blog.sh 1.6

./blog.sh 1.6 is out.

The build stopped rebuilding what nobody changed: a rebuild that changes nothing costs a seventh of what it did, an ordinary publish about two fifths. A post can be handed over as a file — by a script, a cron job, or the new page at /write/ on the blog itself, which sends from a phone over the SSH the server already has. Nothing new listens on the network.

Around those: a row of share controls under a post, an icon a tag can carry, a way out of the trash and the versions, and a photograph stored once instead of twice.

#blogsh #ruby #staticsite