Štítek: ruby

Zabil jsem roboty, pánové

Jednatřicátého července odešel poslední AI digest a druhý den jsem ho smazal ze serveru. Sedm měsíců provozu, tři roboti, tři vydání denně. Fungovalo to od začátku až do posledního dne.

Nemrzí mě to, a to je na tom to nejzajímavější.

Tři roboti, tři nálady

Devátého ledna jsem na Zprávobota nasadil ranní přehled — kolik včera vyšlo příspěvků a pár z nich, co za to podle AI stály. Sedm ráno, k první kávě.

Jenže samotný Zprávobot je docela suchar, tak dostal parťáka. Slunkobot roznášel v poledne výhradně dobré zprávy, protože ke kávičce po obědě nikdo nechce krvák ani propady na burze. Věčně naladěný chlapík, co nezná blbou náladu.

A protože dva jsou málo, přibyl večer třetí do mariáše. Hrubot nosil trochu jiné zprávy a hlavně si k nim neodpustil komentář — ironický, sarkastický, hrubý. Zkrátka Hrubot.

Sedm ráno, poledne, sedm večer. Slušně obsazená směna.

Čtyři dny

Teď to, na co jsem docela pyšný a co bych normálně přešel jednou větou.

První verze vznikla pátého ledna. Do produkce šlo devátého. Mezi tím proběhlo čtyřiatřicet iterací a skript narostl z 566 řádků na 1 873, tedy ze čtrnácti kilobajtů na dvaašedesát.

Čtyři dny od prvního řádku k něčemu, co samo třikrát denně publikuje. A nezapomeňte, prosím, že nejsem žádnej programátor…d8-D

Archos

Tady musím být na chvíli vážný, protože bez jednoho člověka by nebylo vůbec nic.

Během těch čtyř dnů jsem Archosovi poslal k posouzení asi šest verzí po sobě. U té šesté prohlásil, že je to perfektní a že jsem evidentně perfekcionista a hnidopich. A hned nato mi radši udělal přístup na server, aby si zbytek dovolené užil bez mých mailů.

Napsal jsem to tenkrát jako vtip o tom, jak jsem někoho uštval. Dneska vím, že to je nejdůležitější věta z celé té historie.

Do ledna 2026 jsem provozoval instanci, ale na stroj, kde běží, jsem se nedostal. Archos mi nedal jen přihlašovací údaje. Dal mi práva spravovat si to svoje a k tomu něco, co se shání podstatně hůř — trpělivost se mnou. Vydržel šest verzí amatérského skriptu, dokud jsem si neosahal, jak se na serveru pracuje.

Bez toho by nebylo nic z toho, co přišlo potom. A tím myslím doslova nic. Kdybych si na digestech nevyzkoušel, že to jde, do přepisu celého Zprávobota bych se nepustil a dodneška bychom seděli na primitivních IFTTT filtrech.

Takže: díky, Archosi.

Dva dny

A teď se podívejte na kalendář, protože tohle mě dostalo, až když jsem si to nedávno srovnal.

9. ledna jde digest do produkce. 10. ledna dostávám přístup na server. 11. ledna se ptám do pléna, co říkáte na tooty, které nevyrobil IFTTT, ale kus Ruby. 13. února hlásím, že vývoj nového Zprávobota je hotový.

Dva dny. Přesně tolik uplynulo mezi spuštěním digestu a prvním veřejným záběrem systému, který o měsíc později nahradil skoro celého Zprávobota.

Já si tehdy myslel, že si dělám hračku na výběr zpráv. Ve skutečnosti to byla zkušební jízda.

Co po nich zbylo

Digest je pryč, ale rozebral jsem ho na součástky a půlka z nich běží dál.

Bluesky. Na jaře jsem k digestům dodělal publikování na Bluesky a je to podstatně víc práce, než to vypadá — přihlášení přes aplikační heslo, skládání vláken, dělení na tři sta grafémů. A pak facety, tedy to, co v příspěvku udělá z odkazu odkaz. Posílají se zvlášť jako rozsahy v textu a jejich pozice se nepočítají ve znacích, ale v bajtech UTF-8. Na tohle se nedá přijít přemýšlením. Přijdete na to tak, že máte v postu háček, odkaz se posune o dvě místa a rozsype se uprostřed slova.

Vyřešíte to jednou a pak už to umíte. A přesně tenhle kus dneska pohání komentáře pod články na tomhle blogu, jen zeštíhlený na sto čtyřicet tři řádků, protože blog nevydává třikrát denně.

A Hrubot vlastně neumřel. Nový Zprávobot hlídá, co na instanci trenduje, a k vytipovanému postu připíše krátký komentář. Ten komentář nepíše nikdo nový — v kódu je soubor, jehož hlavička poctivě přiznává, že je to port digestového kódu, konkrétně té části, co dělala sarkasmus.

Takže když dneska Zprávobot upozorní na trendující post a přisadí si k němu něco jedovatého, mluví Hrubot. Účet zmizel, povaha zůstala. Ze všech tří to přežil zrovna ten protivnej — Slunkobot by o trendech nejspíš nic hezkého neřekl.

Jak to skončilo

Desátého února jsem se veřejně omlouval, že digesty zanedbávám, protože „jsem jen jeden a nerozmnožuji se dělením". V březnu jsem je ještě jednou pořádně předělal. V dubnu k tomu přibylo Bluesky.

A osmnáctého května jsem napsal jednu větu:

„Čte ty AI digesty na Zprávobotovi ještě někdo? Kdyby ne, dejte vědět."

Na tuhle otázku znáte odpověď dřív, než ji položíte. Kdyby to někdo četl, nemuseli byste se ptát.

Poslední digest vyšel jednatřicátého července. Kód zůstal na GitHubu, záloha na serveru. Nemazal jsem to proto, že by to nefungovalo — fungovalo to skvěle. Mazal jsem to proto, že provozovat něco, co nikdo nečte, je jen forma zdvořilosti vůči vlastní minulé práci.

A přesto

Kdybych to měl shrnout: postavil jsem tři roboty, sedm měsíců je krmil, nakonec je vypnul a nikdo si toho nevšiml.

Zní to jako průšvih, ale rozhodně není.

Ten projekt měl totiž jediný skutečný výstup a nebyly to digesty. Byl to přístup na server, znalost Ruby a hlavně důkaz, že se do velké věci můžu pustit. To všechno odevzdal během prvního týdne. Zbylých sedm měsíců byl bonus, který navíc rozdal svoje součástky dvěma dalším projektům.

Nejlepší vedlejší produkt, jakej jsem kdy vyrobil. A stál mě čtyři večery mezi svátky.

Číst dál

Čtyři dny blogu

Za poslední dny, i když se to nezdá, jsem na blogu udělal 116 commitů ve 24 pull requestech. Něco z toho je vidět přímo tady na webu, to podstatné se ale odehrálo pod kapotou. Ale nejdřív prostý výčet toho, co se povedlo:

Co je hotové

  • audio jako plnohodnotný typ postu, citace s atribucí a chat posty
  • tvrdá zalomení a task listy v markdownu
  • médium si post nárokuje jen tehdy, když je text opravdu popisek — 104 starých příspěvků se tím přesunulo do správného typu
  • menu ukazuje jen typy, které web skutečně má, takže AUDIO ani CHAT tu zatím neuvidíte; objeví se samy s prvním takovým postem
  • aktivní položka v menu je konečně barevně odlišená a první položka se jmenuje Vše, ne Domů
  • časová zóna se respektuje i při zobrazení, nejen při zápisu
  • naplánované publikování — draft s budoucím datem se pustí ven sám
  • vlastní favicon a ikona pro plochu na iOS
  • widget s posledními příspěvky z Bluesky a obecný widget na jakékoliv RSS

Pod kapotou — z nástroje pro jeden blog je projekt

Tohle je ta část, kvůli které se ty čtyři dny nedají shrnout do odrážky.

Nástroj, o kterém jsem tu celou dobu psal, přestal být nástrojem jen pro tenhle web. Vytáhl jsem engine do separátního repozitáře pod názvem blog.sh, dal mu licenci MIT a od 30. července je veřejný.

Skoro to i platilo. Skoro — protože to znamená, že v kódu nesmí zůstat nic mého. Barvy webu, které byly natvrdo v CSS, se přestěhovaly do konfigurace: sedm barev pro světlý a sedm pro tmavý režim, zbytek se z nich dopočítá.

Všechny texty se přeložily do skutečných locale souborů, takže engine mluví česky, anglicky a nově i německy — a to jak na webu, tak i v průvodci v terminálu. Konfigurace a grafika každé instalace žijí mimo git, aby si kdokoliv mohl aktualizovat kód, aniž by si přepsal vlastní banner.

Nejlepší test je druhá instalace, takže existuje: blogsh.app, web o tomto enginu, postavený tímto enginem. Dva weby na jednom kódu odhalí za týden víc než měsíc přemýšlení, co by se mohlo pokazit.

Změnil se i způsob, jakým se sem dostávají změny. Dřív jsem kód na server kopíroval rsyncem. Dnes je server obyčejný git klon a nová verze se nasazuje git pullem — sean.cz sleduje moji pracovní větev, blogsh.app jen to, co je vydané.

Z jednoho migračního skriptu se také mezitím stal celý importní podsystém. Umí sedm zdrojů — Bluesky, Mastodon, Pixelfed, Tumblr, Twitter/X, WordPress s RSS a od včerejška Instagram — a ke každému se dostanete jak průvodcem, tak skriptem, přičemž pod tím běží jediná implementace. Import taky přestal být operace, kterou spustíte omylem: potvrzuje se opsáním počtu příspěvků, ne stiskem y. Když jednou naliju do webu dva tisíce cizích postů, chci to mít na svědomí vědomě.

Podobně se otevřelo nasazování. Surfer, který tenhle web hostuje, byl původně jediná možnost; dnes jsou vedle něj místní adresář, rsync, git (a tím i GitHub Pages), rclone a SFTP. Pojistky proti tomu, aby rozbitý build smazal půlku webu, platí pro všechny stejně.

A protože komentáře pod články sem tahám z Mastodonu, dostala stejnou možnost i druhá síť: web může komentáře a oznamovací příspěvky vést přes Bluesky. Buď jedno, nebo druhé — sčítat reakce ze dvou sítí do jednoho vlákna nedává smysl.

Nakonec terminál. Průvodce, o kterém jsem psal minule jako o číslovaném menu, umí šipky, jednoklávesové odpovědi a vlastní QR kód — po založení draftu se rozsvítí na obrazovce a jeho vyfocením mám náhled rozepsaného textu v telefonu. Žádná knihovna, ten kodér je taky součást repozitáře.

Zbytek těch čtyř dnů spolykala dokumentace a testování na cizích strojích — instalace na Macu, na Linuxu a na Windows přes WSL. Cíl je jednoduchý: kdo si to chce nainstalovat, ať to zvládne bez ptaní.

Chyby, které to vytáhlo na světlo

  • nový příspěvek se stejným názvem ve stejném roce tiše přepsal ten starší
  • nedotčený draft se publikoval, jako by měl ručně zadané datum, a přišel tím o oznamovací příspěvek — tedy i o komentáře
  • odstavec zabalený do <div> se rozsekal na blok za každý kus a cestou přišel o tučné písmo
  • HTML entity, které nebyly v převodní tabulce, mizely i s písmenem uprostřed slova
  • příliš dlouhý nadpis vyrobil čtyřsetznakovou adresu příspěvku
  • náhled importu hlásil nula médií, tedy přesně to číslo, kvůli kterému náhled existuje
  • neodpovězený dotaz na zdroj importu vybral poslední položku místo toho, aby akci zrušil
  • drafty nešlo smazat z nabídky hned po uložení
  • politika obsahu tiše blokovala skript s překlady, takže se texty vykreslované v prohlížeči zobrazovaly prázdné a nic to nehlásilo

Co dál?

Soustředil jsem se teď na release verze 1.0, přípravu webu ./blog.sh, něco designu, a také už připravuji další funkcionality. Pár nápadů už mám. Hlavně ale zítra bude ten den, kdy to všechno vypustím do světa a jsem zvědavý, zda budou vůbec nějaké reakce a zda budou pozitivní, a nebo ne. Těším se, a zároveň mám i obavy. Uvidíme.

Číst dál

Kdo se moc ptá, moc se dozví

Po vší té práci na blogu jsem chtěl vědět, co by šlo na blogu zrychlit, co vyhladit, co utěsnit, a tak jsem se na to zeptal Fable - nejnovějšího LLM modelu Claude od
Anthropic. Čekal jsem, že mi dá pár nápadů na později a dostal jsem osm commitů, přepsaný velký kus architektury a opravu bugu, o kterém jsem ani netušil.

Půlka archivu, o které jsem nevěděl

První věc, na kterou analýza narazila, byla, že content/posts/ mělo najednou 5 822 souborů místo 3 274. iCloud si při nějaké synchronizaci sám pro sebe vyrobil 2 548 kopií — se jménem <slug> 2.json, macOSí konvencí pro konfliktní soubor. Build by to tiše sežral a zdvojil by se mi celý archiv, výpisy, RSS, sitemapa i vyhledávání.

Server je jediný autoritativní zdroj, tak jsem to neopravoval, lokální kopii prostě zahodil a stáhl znovu — rsync --checksum --delete. Vedlejší bonus: několik nových a dva upravené posty se mi nově stáhlo přímo ze serveru.

Aby se tohle příště nestalo, přidal jsem do buildu pojistku: když dva posty vyjdou na stejný rok a slug, celý build spadne s jasnou hláškou místo tichého zdvojení. A do nahrávání na server zase zrcadlovou pojistku — když se počet souborů k nahrání najednou zvýší o víc než pětinu, deploy se zastaví. Dřív hlídal jen prudký úbytek (rozbitý build), ne prudký nárůst.

Fonty, hlavičky a jeden zapomenutý roh archivu

Web dosud tahal Open Sans z Google Fonts — cizí origin do kritické cesty vykreslení a IP každého návštěvníka mířící rovnou Googlu. Stáhl jsem si font k sobě a díky tomu zjistil, že aktuální verze Open Sans na Google Fonts je variabilní font, takže tři váhy (400/600/700) sedí v jediném souboru na jazykový subset a budu mít dva soubory místo šesti.

Po fontech přišla na řadu Content-Security-Policy, a ta odhalila něco 29 starších postů z migrace s vloženým syrovým embed videem z youtube.com, ne z bezpečnějšího youtube-nocookie.com, jak to dělají nová videa. CSP musela počítat s oběma, jinak by starým videím zčerstva přestalo fungovat přehrávání.

Dva posty také táhly skript z platform.instagram.com — čistý trackovací kód z dob migrace. Ten jsem z dat vystřihl úplně a nahradil obyčejnou kartou s odkazem.

Jednu díru jsem naopak zavřel jen na papíře, protože se zavřít nedá: Surfer, kam se web nahrává, bere přihlašovací token jako součást URL, takže končí v access logu. Ověřil jsem, že token přes hlavičku Authorization prostě na Surfer nejde poslat, a u nahrávání souborů (naprostá většina provozu) by ani tělo požadavku nepomohlo. Zůstává to tedy tak, jak to je, se zdůvodněním rovnou v kódu, ať se za k tomu za rok nevracím se stejnou otázkou.

.nosync, aneb jak nesyncovat, co nepotřebuju

Lokální public/ jsem měl na Macu v iCloud Drive — každý build tak přepisoval stovky megabajtů, co iCloud hned začal poctivě nahrávat pryč, přestože jde o čistě odvozený výstup, který jde kdykoli
znovu "vyrobit". Přejmenování na public.nosync iCloud ze sledování vyřadí. Je to nedokumentované chování, ale ověřil jsem si ho přímo: xattr na přejmenovaném adresáři ukáže
com.apple.fileprovider.dir#N, kterou synchronizovaný adresář nemá.

Stejná úvaha platila i pro content/ a media/ — takže i tyhle dva adresáře dostaly .nosync a lokální build, který ještě ráno neproběhl ani za deset minut kvůli iCloudí I/O režii, teď proběhne
za deset vteřin.

Vyhledávání, které se učilo za pochodu

Index pro vyhledávání rostl s každým postem a stahoval se celý na každou návštěvu /search/ — 488 kB komprimovaně, i když návštěvník hledal něco z minulého týdne. Rozdělil jsem ho na dva: pět set nejnovějších postů se stáhne hned, zbytek archivu líně, až někdo skutečně něco zadá do políčka.

Cheat sheet přestal být rukojmím jednoho postu

Nápověda pro markdown syntaxi žila jako obyčejný publikovaný post, a odkaz na ni v editoru byl natvrdo zadrátovaný na jeho adresu. Kdybych ho někdy smazal nebo odpublikoval, nápověda by mířila do prázdna — nikdo by o tom nevěděl, dokud by na to nekliknul.

Vytáhl jsem markdown parser (270 řádků, co dosud žily zavřené v nástroji pro psaní postů) do vlastní knihovny a naučil build stavět i stránky mimo content/posts/. Cheat sheet je teď /markdown/ — generuje se přímo ze souboru v repozitáři při každém buildu, stejně jako se dnes generuje vyhledávání. Nejde ho smazat ani odpublikovat, protože to není post. Existuje, dokud existuje ten soubor.

Jak to dopadlo

  • lokální build: z přes 10 minut na 10 vteřin
  • soubory k hashování při běžném nasazení: ze všech ~7 700 na jen změněné
  • search-index.json na každou návštěvu vyhledávání: z 488 kB na 137 kB, zbytek líně
  • requesty na Mastodon při běhu cronu statistik: ze všech tootovaných postů na ty z jen posledních 90 dní (starší jednou týdně)
  • CSP hlavička, self-hosted fonty, žádný trackovací skript v datech: 0 → hotovo
  • osm commitů, 18 souborů, skoro devět set přidaných řádků — a jedna oprava bugu, o kterém jsem ještě ráno nevěděl

Chtěl jsem krátký přehled, ale Fable mi dal kompletní analýzu včetně řešení a oprav.

Číst dál

Draft, náhled, koš — jak dneska pracuju s příspěvkem

Když jsem tu naposledy psal o tom, jak je tenhle blog postavený, manage-post.sh uměl v podstatě tři věci: napsat, upravit a smazat. Fungovalo to, ale mělo to pár much, na které jsem přišel až za provozu — a protože je psaní kódu k tomuhle blogu skoro stejná zábava jako psaní příspěvků, sedl jsem si a předělal to. Tenhle post je o tom, jak s příspěvkem pracuju teď.

Draft, ne rovnou na web

add nikdy nepublikuje rovnou. Vždycky vznikne draft s vlastní skrytou adresou (/draft/<náhodný token>/...) — dá se otevřít z telefonu i uprostřed rozepsaného textu, ale nikdo jiný ji neuhodne, protože není nikde odkazovaná ani v žádném výpisu. Teprve když jsem s textem hotový, přijde na řadu skutečná otázka: publikovat, nechat rozepsané jako draft, nebo se vrátit zpátky do editoru.

Náhled na draft postu pro blog
Náhled na draft postu pro blog

Publish uměl jednu ošklivou věc

Tohle byla ta nejzávažnější chyba, na kterou jsem přišel: publish post publikoval okamžitě, jakmile jsem vybral slug ze seznamu. Žádný náhled, žádné "jsi si jistý" — jen holé rozhodnutí udělané za mě. Chtěl jsem se ale ještě podívat, jak draft vypadá, než ho pustím ven. Teď publish udělá přesně to, co add po založení — ukáže náhled a zeptá se, co dál. Skutečnou publikaci spouští až vědomá volba v tom menu, ne samotné zavolání příkazu.

Unpublish — zpátky do šuplíku

Přibyla i opačná operace: unpublish vrátí publikovaný příspěvek zpátky do draftu. Hodí se, když si po publikaci všimnu překlepu, který nechci opravovat narychlo, nebo když se rozhodnu text ještě pořádně přepracovat. Součástí je i smazání přidruženého tootu na Mastodonu přes jeho API — jinak by po dobu, než příspěvek zase publikuju, na síti visel odkaz na stránku, která dočasně neexistuje. Při další publikaci navíc dostane nové, aktuální datum — chová se to jako čerstvě napsaný text, ne jako by se jen tiše přemístil v archivu.

Koš místo nevratného smazání

delete dřív smazal JSON i všechna přidružená média natvrdo. Žádný git u obsahu, žádná záloha — jeden překlep ve slugu a je to pryč. Teď delete přesouvá všechno do trash/, a restore <slug> to vrátí přesně tam, kde to bylo. Není to plnohodnotná historie verzí, jen jedna úroveň zpět — ale přesně to mi chybělo.

Toot na požádání

Někdy zůstane příspěvek bez tootu zajišťujícího vaše reakce z Mastodonu — třeba u starých migrovaných věcí z Tumblru, nebo když se odeslání tootu při publikaci nepovede (výpadek sítě, expirovaný token). Dřív jsem s tím nemohl nic dělat jinak než ručně. Nový příkaz toot <slug> pošle komentářový toot dodatečně, ke komukoliv publikovanému, kdykoliv. Existující toot ale nikdy nepřepíše — když už jeden je, druhý se neposílá, protože pro to není důvod.

Průvodce pro dny, kdy zapomenu příkazy

Nástroj má dnes devět různých akcí a nechci si je pamatovat nazpaměť pokaždé, když sednu k psaní. Stačí teď spustit ./blog.sh bez ničeho a naskočí jednoduché číslované menu — vyberu si, co chci udělat, a po dokončení se vrátím zpátky do stejného menu, dokud neřeknu konec. Přesná stejná logika jako předtím u CLI, jen v přívětivější podobě pro dny, kdy nechci nic ťukat z hlavy.

Průvodce funkcemi blogu
Průvodce funkcemi blogu

Přejmenování a co bude dál

Nakonec i drobnost: manage-post.sh jsem přejmenoval na blog.sh — název, který sedí líp k tomu, co ten nástroj dneska umí, a protože nezůstal svázaný jen s prací nad jedním příspěvkem. Zbytek pojmenování v repozitáři (deploy-web.sh, refresh-sidebar.sh) jsem záměrně nechal být — dělají jinou, úzce vymezenou práci a přejmenování by tomu nic nepřidalo.

Číst dál

Když změna jednoho řádku CSS znamená nahrát šest tisíc souborů

Chtěl jsem tu změnit barvu odkazů. Jeden řádek ve stylech. Spustil jsem deployment a s hrůzou v očích koukal, jak se na server nahrává šest tisíc souborů.

Nic se nerozbilo, ale bylo jasné, že něco je velký špatný.

Může za to vychytávka

Každá stránka měla v hlavičce otisk obsahu stylů:

<link rel="stylesheet" href="/assets/css/site.css?v=a3f9c21b04">

Když se styly změní, změní se adresa a prohlížeč si stáhne novou verzi místo té nacachované. Dal jsem to tam schválně a byl na to docela pyšný.

Jenže se tím otisk propsal do každé stránky webu. Změním jeden znak v CSS, změní se všech 6 228 stránek — a deploy skript, který umí posílat jen to, co se skutečně změnilo, to poslušně pošle všechno.

Když jsem se podíval, co vlastně moje úložiště posílá prohlížečům, došlo mi to:

$ curl -sI https://sean.cz/assets/css/site.css
cache-control: public, max-age=0
etag: W/"409e-19f9a201251"

max-age=0 znamená, že se prohlížeč u každého načtení stejně ptá, jestli se soubor nezměnil. Ta vychytávka nebyla nikdy potřeba. Řešila problém, který jsem neměl, a způsobila ten, co jsem měl.

Nový příspěvek přepsal celý archiv

Stránky výpisu se stránkovaly odshora: deset nejnovějších na první stranu, dalších deset na druhou. Nový příspěvek se zařadí na začátek a posune všechno ostatní — hranice všech 328 stran se posunou s ním. Nový post tak znamenal přepsat cca 435 souborů.

Spravilo se to obrácením směru. Desítky se teď počítají od nejstaršího příspěvku, takže nejstarší desítka je navždycky stejná a nový text se přidá jen do té poslední, rozepsané stránky.

Daň je, že /page/1/ dneska znamená nejstarší příspěvky, ale to nevadí, protože se na ně chodí přes „Novější / Starší".

Věc, kterou jsem nečekal

Při ověřování jsem přidal zkušební příspěvek a čekal devět změněných souborů. Vyšlo jich 65 — a byly mezi nimi stránky, které s tím textem nemohly mít nic společného.

Ukázalo se, že když web přegeneruju dvakrát za sebou, pokaždé vyleze trochu jiný výsledek. Příspěvky se řadí podle data a Ruby negarantuje, který ze dvou se stejným datem skončí první. A po stěhování z Tumblru a Twitteru má 110 příspěvků úplně stejné datum jako nějaký jiný.

Nic nezměním, spustím build a přesto mám změněné soubory. Vůbec jsem to netušil. Oprava je jeden řádek: když je datum stejné, rozhodne jméno příspěvku.

Měřit, ne hádat

Byl jsem si jistý, co bude nejdražší operací — kopírování 384 MB fotek. Změřil jsem to a představa se rozsypala. Fotky: jedna sekunda.

Nejdražší bylo mazání složky s hotovým webem, 3,8 sekundy. Build ji vždycky smazal celou a hned do ní nasypal zpátky skoro totožných 7 700 souborů. Teď se nemaže; zapisuje se jen to, co se opravdu liší.

Dvě věci mimo rychlost

Komentáře pod články tahám z Mastodonu a jméno autora jsem vkládal do stránky bez ošetření. Jenže jméno účtu si nastavuje kdokoli ve Fediverse — stačilo se přejmenovat na kus kódu, odpovědět mi na toot a nechat si ho spustit v prohlížeči každému čtenáři. Učebnicová díra. Zavřeno.

A postranní panel si tahal poslední tooty a commity přímo v prohlížeči návštěvníka: čtyři a víc dotazů na cizí služby při každém otevření jakékoli stránky. GitHub navíc nepřihlášené pouští jen šedesátkrát za hodinu, takže ta kartička po chvíli mizela. Teď si data stáhne server.

Jak to dopadlo

  • změna stylů: z 6 228 souborů na jeden
  • nový příspěvek: z ~435 na sedm až devět
  • přegenerování beze změny obsahu: z desítek tiše změněných souborů na nulu
  • dotazy na cizí služby při otevření stránky: ze čtyř a víc na žádný
  • obrázky stažené na úvodní stránce: z desítek na jeden
  • build: ze třinácti sekund na sedm a půl

Dvě poučení. Byl jsem si jistý, že problém jsou fotky, a byla to složka, na kterou bych nesáhl ani po dlouhém přemýšlení. A to prohazování pořadí nikdy nespadlo, nic nenahlásilo, nic nerozbilo — jen potichu dělalo práci navíc.


Jestli jste četli Jak je postavený tenhle blog, tak ho tenhle článek na pár místech přepisuje — tooty ani commity už se v prohlížeči netahají a složka s hotovým webem se nemaže. Původní text nechávám být; popisoval stav, který tehdy platil.

Číst dál

Jak je tenhle blog postavený

Když jsem se rozhodl opustit Tumblr a mít svůj obsah konečně pod vlastní kontrolou, věděl jsem jedno: nechci žádný Wordpress, Ghost ani jiný hotový CMS s databází, pluginy a věčnými aktualizacemi. Chtěl jsem něco tak jednoduchého, aby to šlo i za deset let přečíst a opravit, i kdybych už sám zapomněl jak na to.

Tohle zároveň z tohohle web/logu dělá geekovskou, ne-li nerdovskou záležitost, protože nikdo normální (ať už to znamená cokoliv) nepíše posty v Markdownu na serverovém terminálu...d8-D

Markdown tohoto článku

Žádná databáze, jen soubory

Každý příspěvek je jeden JSON soubor. Žádné MySQL, SQLite, ORM. Obsah příspěvku je pole bloků — text, obrázek, video, odkaz, seznam — ve stejném formátu, jaký používá Tumblr pro svoje NPF (Neue Post Format). Díky tomu mají historické importy z Tumblru a Twitteru úplně stejnou strukturu jako příspěvky, které píšu ručně dnes. Žádná zvláštní výjimka, žádný "legacy formát".

Fotky a videa leží vedle postu — nikdy nic neodkazuju na cizí server (žádný hotlinking), všechno je stažené a uložené lokálně už při importu.

Build: statický web z JSONů

build_blog.rb je jeden Ruby skript, který projde všechny JSONy a vyrenderuje z nich statické HTML. Žádný Jekyll, Hugo ani Next.js — jen ERB šablony a hrst funkcí, které skládají HTML ručně. Build mimo jiné:

  • generuje stránkování po desítkách příspěvků, archivy podle štítků a podle typu obsahu (text/obrázky/video/odkazy),
  • staví JSON index pro vyhledávání a RSS/sitemap,
  • řeší formátování textu (tučně, kurzíva, odkazy, přeškrtnutí) podle offsetů v poli formatting — stejný princip jako u Tumblr NPF,
  • automaticky seskupuje po sobě jdoucí obrázkové bloky do fotogalerie.

Výsledek je čistá hromada statických souborů, kterou umí obsloužit úplně cokoliv.

Psaní: CLI místo administrace v prohlížeči

Zvažoval jsem administrační rozhraní v prohlížeči, ale místní HTTP server, který umí zapisovat soubory, otevírá zbytečná rizika (CSRF z jiné otevřené záložky, path traversal a podobně). Místo toho mám manage_post.rb, který otevře $EDITOR na markdown souboru s YAML frontmatterem (přesně takhle vypadá i tenhle článek, než se zpracuje). **tučně**, *kurzíva*, [odkaz](url) a ![popisek](obrázek "titulek") se převedou na stejné content bloky, jaké produkují importy z Tumblru a Twitteru — píšu tedy stejným "jazykem", jakým jsou uložené i příspěvky staré přes deset let.

Skript umí add, edit <slug>, delete <slug> i list s filtrem podle typu nebo štítku, a po uložení příspěvku rovnou nabídne rebuild webu.

Publikování z mobilu

Když píšu z iPadu nebo z telefonu přes SSH, nemám po ruce lokální cestu k fotce. Vyřešil jsem to složkou incoming/ na serveru, kam nahraju fotku přes SFTP, a v Markdownu pak stačí napsat jen ![popisek](foto.jpg) — holé jméno souboru se automaticky přeloží na incoming/foto.jpg. Pokud soubor v tu chvíli ještě neexistuje, skript počká a opakovaně kontroluje, dokud fotku nenahraju nebo uložení nezruším. Díky tomu můžu nejdřív napsat celý text a fotky dohnat později.

Mastodon jako vrstva pro komentáře

Nemám (a ani nechci) vlastní systém komentářů. Každý nový příspěvek se automaticky tootne na Mastodon (mastodon_poster.rb), a odkaz na ten toot se uloží k příspěvku jako mastodon_url. Na stránce příspěvku pak JavaScript (comments.js) stáhne přes veřejné Mastodon API odpovědi na ten toot a zobrazí je jako komentáře — se jménem, avatarem a časem. Žádná vlastní databáze komentářů, žádná moderace navíc — to všechno dělá Mastodon. Stejný widget zobrazuje i počet favů, boostů a odpovědí přímo pod textem příspěvku.

Widgety v postranním panelu

V patičce/postranním panelu žijou tři malé JS widgety, každý s jiným zdrojem dat a jiným způsobem načtení:

  • Poslední tooty — natahují se za běhu v prohlížeči přímo z Mastodon API.
  • Poslední commity — totéž, ale z GitHub API (poslední push eventy mého účtu).
  • Poslední Pixelfedy — na rozdíl od těch dvou se stahují už při buildu na serveru (build_blog.rb), protože Pixelfedí Atom feed nemá CORS hlavičku a z prohlížeče by nešel načíst.

Vyhledávání a další drobnosti

Vyhledávání je čistě klientské: build vygeneruje search-index.json se všemi příspěvky (bez diakritiky, malými písmeny), prohlížeč si ho stáhne jednou a pak nad ním vyhodnocuje dotazy s podporou frází v uvozovkách a vylučování slova pomocí -slovo. Žádný Algolia, žádný server-side search.

Zbytek je už jen běžná výbava: přepínač světlého/tmavého motivu (CSS proměnné + prefers-color-scheme), lightbox na obrázky, sbalovací navigace na mobilu — všechno bez jakéhokoli frameworku, čistý vanilla JS v malých samostatných souborech.

Nasazení: chytrý sync na Surfer

Build maže a znovu vytváří celé public/, takže se nedá spoléhat na čas změny souborů — každý build má nové mtime úplně na všem. deploy-web.sh proto drží mimo public/ manifest SHA-256 hashů všech souborů a při nasazení nahraje na úložiště (Cloudron Surfer, přes jeho Files API) jen to, co se opravdu změnilo. Výsledek: rebuild tří tisíc příspěvků neznamená při každém nasazení upload tří tisíc souborů, ale jen těch pár, co se skutečně změnily.

Odkud se to sem všechno dostalo

Blog neběží od nuly — naimportoval jsem do něj historii ze čtyř Tumblr blogů a z archivu vlastních tweetů z Twitteru/X (2008–2022, jen samostatné příspěvky, žádné odpovědi ani retweety). Oba importy (migrate_tumblr.rb, migrate_twitter.rb) publikovaly posty přes post_writer.rb, který používá i ruční psaní, takže žádný příspěvek nemá "výsadní" postavení podle původu.

Proč takhle

Celé je to sázka na to, že obyčejné soubory, obyčejný Ruby skript a hrstka JavaScriptu bez frameworku vydrží déle a budou se snáz opravovat než cokoliv postavené na cizí platformě nebo cizí databázi. Nic tu neběží jako služba, kterou by mi někdo mohl vypnout nebo zpoplatnit — jen soubory, které mám u sebe a rozumím jim.

A přesně tak jsem to chtěl...d8-D

Číst dál