Štítek: sean.cz

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

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