Artykuły
Wielojęzyczny blog „wpisy + artykuły” zbudowany wyłącznie we froncie
Moją stronę zbudowałem jako wielojęzyczny blog „wpisy + artykuły”: treści zarządzam w Markdownie, tłumaczenie powstaje przy publikacji, strony statyczne generuje build, a przeglądarka zajmuje się czytaniem i przełączaniem języka. Omawiam model treści, wspólny pipeline, ładowanie na żądanie i kompromisy SEO oraz to, co dokładnie oszczędza podejście frontend-only.
· Zaktualizowano · 7 min czytania
Chciałem, żeby moja strona pozwalała zarówno szybko zapisać jedną myśl, jak i opisać w całości problem techniczny. Krótkie rzeczy trafiają do „wpisów”, długie do „artykułów”, oba rodzaje mogą się wzajemnie linkować, a gdy odwiedzający zmienia język, treść zmienia się razem z nim.
To wymaganie wygląda zwyczajnie, ale jeśli od początku dodam bazę danych, panel administracyjny, API treści, a potem jeszcze wersje dla każdego języka, szybko powstanie kolejny produkt, który trzeba długo utrzymywać.
Postanowiłem zawęzić wymagania do tego, z czego naprawdę korzystam: jeden autor, treści w Gitcie, przebudowa strony po publikacji, a po stronie czytelnika wyłącznie pliki statyczne i przeglądarka. Tak właśnie wygląda dziś architektura „wpisy + artykuły” w stephen.taipei.
Frontend-only dotyczy strony czytelnika#
Najpierw wyraźnie zaznaczę granice. Projekt używa Reacta, Vite i Tailwinda, a przetwarzanie treści, konwersja obrazów i skrypty budujące nadal wymagają Node.js; tłumaczenie również wywołuje zewnętrzną usługę na etapie publikacji. Po wdrożeniu pliki statyczne serwuje Nginx.
Gdy mówię „frontend-only”, mam więc na myśli to, że gdy czytelnik otwiera stronę, nie potrzeba dedykowanego backendu bloga, który pobierałby dane, składał strony ani tłumaczył w czasie rzeczywistym. Jest proces budowania i jest serwer, ale nie ma działającego cały czas API aplikacji ani bazy danych tylko po to, żeby czytać artykuły.
Gdy pracę wykonam z wyprzedzeniem, strona czytelnika może być znacznie prostsza.
Wpisy i artykuły: wspólne źródło, osobne modele#
Treści leżą w content/, a oryginały są w Markdownie. Obu rodzajów nie wciskałem na siłę w jeden format:
content/
tags.yaml
tags.locales.yaml
notes/2026/20261004-0944.md
notes/2026/20261004-0944.locales.yaml
articles/frontend-only-multilingual-blog/
index.md
locales.yaml
cover.webp
Wpis to krótka karta, której nazwa pliku używa minuty w czasie tajpejskim jako id; można go oznaczyć jako myśl, czegoś się nauczyłem, wdrożenie, wpadka lub lektura. Wystarczy jeden akapit tekstu, nie trzeba za każdym razem wymyślać tytułu. Powyżej 500 znaków pojawia się przypomnienie, a powyżej 1000 znaków to już powinien być artykuł.
Artykuł ma stały slug, tytuł, streszczenie i datę, a opcjonalnie okładkę oraz czas aktualizacji. Po opublikowaniu adres się nie zmienia, żeby udostępnione wcześniej linki nie przestały działać.
Oba rodzaje łączy powiązanie article:. Wpis może najpierw zanotować problem, a potem wskazać pełny artykuł; strona artykułu w drugą stronę wylicza wpisy, które się do niego odwołują. Krótka treść zachowuje punkt wyjścia z danej chwili, a długa dopisuje cały tok rozumowania, więc nie trzeba wklejać tego samego tekstu w obu miejscach.
Reguły treści zatrzymują błędy przed buildem#
Jeśli Markdown jest po prostu wrzucany na ekran, wiele problemów wychodzi dopiero po wdrożeniu: literówka w dacie, artykuł, do którego nie da się dojść, obraz bez alt albo ten sam temat opisany trzema podobnymi tagami.
Te reguły umieściłem we wspólnym pipeline treści. loadContent wczytuje pliki, sprawdza pola za pomocą Zod, a następnie przetwarza Markdown, obrazy, języki i powiązania między treściami. Z tego samego pipeline korzystają zarówno kontrole w CLI, jak i build Vite, co zmniejsza ryzyko sytuacji „lokalnie działa, a na produkcji nie”.
Tagi akceptują tylko klucze zarejestrowane w tags.yaml; artykuły i inne wpisy, do których odwołuje się wpis, muszą istnieć; lokalny podgląd może czytać szkice, a produkcyjny build je pomija. Te reguły nie polegają na tym, że pamiętam o nich przy publikacji, tylko na tym, że kontrola faktycznie blokuje błędy.
Markdown ma też wyraźną granicę wyjścia: surowy HTML nie jest wypuszczany wprost, wygenerowany HTML jest oczyszczany, a bloki kodu są podświetlane już podczas buildu. Obrazy treści wskazywane lokalną ścieżką względną są zamieniane na WebP o szerokości najwyżej 1600 px, z usuniętym EXIF, w tym GPS; artykuł, karty i podgląd udostępniania korzystają ze wspólnego, przetworzonego obrazu. Obrazy zdalne i obrazy ze ścieżką bezwzględną od katalogu głównego nie przechodzą przez tę lokalną konwersję.
Koszt wielojęzyczności płacę przy publikacji#
Strona obsługuje obecnie 22 języki. Oryginał po chińsku tradycyjnym piszę raz, a tytuły, streszczenia, treść i opisy obrazów w pozostałych 21 językach trafiają do locales.yaml obok.
Jedna decyzja jest dla mnie szczególnie ważna: czytelnik nie czeka na tłumaczenie. content:localize generuje tłumaczenia przy publikacji, a właściwy build czyta wyłącznie istniejące już pliki i nie wywołuje usługi tłumaczeniowej przy każdym wdrożeniu.
Oryginał i tłumaczenia są zestawiane za pomocą source digest. Gdy oryginał się zmieni, stare tłumaczenie nie może być już traktowane jako aktualne; jeśli brakuje któregoś języka lub tłumaczenie jest nieaktualne, content:check kończy się błędem. W trakcie tłumaczenia kod, inline code i cele linków są chronione, żeby nie przetłumaczyć przy okazji identyfikatorów technicznych ani adresów URL.
Nie znaczy to, że automatyczne tłumaczenie dorównuje jakością redakcji ludzkiej. Rozwiązuje ono kompletność i spójność aktualizacji, a terminologia i ton nadal wymagają korekty. Niezmieniony oryginał zachowuje dotychczasowe tłumaczenie, a poprawki wprowadzone ręcznie nie zostaną nadpisane przy ponownym uruchomieniu.
Lista czyta streszczenia, artykuł ładuje się dopiero po wejściu#
Gdy treści przybywa, ładowanie na stronie głównej pełnych tekstów każdego artykułu w każdym języku spowolni nawet aplikację frontend-only.
Wtyczka Vite dzieli treści na trzy punkty wejścia: virtual:content/latest dla strony głównej, virtual:content/index dla stron treści oraz osobne virtual:content/article/<slug> dla każdego artykułu. Strona główna bierze trzy najnowsze wpisy i trzy karty artykułów; dane wpisów nadal zawierają pełny tekst i tłumaczenia, tylko na karcie są obcinane. Karta długiego artykułu niesie jedynie streszczenie i podobne dane, a pełną treść ładuje osobno dla każdego artykułu dynamic import.
Suspense w Reakcie czeka na treść, a zewnętrzna granica błędów (error boundary) pokazuje komunikat, gdy ładowanie się nie powiedzie. Jeśli żądanie treści zawiedzie, tytuł, streszczenie i nawigacja po stronie nadal pozostają.
Jest tu też kompromis: obecnie jeden artykuł to jeden chunk z treścią, który zawiera tłumaczenia na wszystkie języki; nie rozdzieliłem go jeszcze na jeden chunk na język. Pełne teksty wpisów i ich tłumaczenia trafiają do indeksu treści. Gdy treści będzie przybywać, rozmiar indeksu i ilość pobieranych wielojęzycznych tekstów to kolejne miejsca do zmierzenia.
Obok przełączania we froncie potrzebne jest też statyczne wejście do czytania#
Sama aplikacja SPA często powoduje, że po udostępnieniu widać tylko tytuł i obraz strony głównej. W przypadku artykułów to za mało.
Dlatego podczas buildu każdy adres wpisu, artykułu i tagu dostaje własną powłokę HTML z title, description, canonical, Open Graph i JSON-LD, a w noscript umieszczana jest treść do przeczytania w danym języku. Nawet bez uruchamiania JavaScriptu można dotrzeć do treści artykułu i jego własnej okładki.
Z tej samej treści powstają też feed.xml, sitemap-content.xml, wersja artykułu w Markdownie oraz indeks artykułów w llms.txt. Wszystko to artefakty buildu, a nie osobny zestaw treści wymagający ręcznej synchronizacji.
Trzeba też jasno opisać podejście do SEO wielojęzycznego: wpisy, artykuły i tagi mają w każdym języku własny adres. zh-TW używa /articles/<slug>, pozostałe języki dodają prefiks, na przykład /en/articles/<slug>. Podczas buildu każdy język dostaje własne strony statyczne z self-canonical i hreflang, a wszystkie trafiają do sitemap-content.xml; feed i wersje Markdown pozostają w języku oryginału.
Strona główna ma jeden adres i serwuje wstępnie wyrenderowane wersje zależnie od języka przeglądarki oraz trybu jasnego lub ciemnego. Każdy z 22 języków danej treści to samodzielna strona, którą wyszukiwarki mogą indeksować osobno.
Publikacja to też linia dostawy#
Mój proces publikacji wygląda tak: napisać oryginał, wygenerować tłumaczenia, sprawdzić treść, zatwierdzić w Gitcie, a następnie scalić do main. Istniejące GitHub Actions przebudowują stronę i ją wdrażają.
npm run content:localize
npm run content:check
npm run test:content
npm run build
Jeśli wersje językowe nie są kompletne, najpierw je naprawiam. Jeśli artykuł jest szkicem, zostaje w lokalnym podglądzie. Produkcyjne wyjście sprawdza dodatkowo, że strony szkiców nie istnieją, i skanuje główne publiczne wyjścia, takie jak feed, sitemap, strona główna i bundle JavaScript, pod kątem znaczników szkiców. Treści, tak jak kod, mają diff i wersje, i można je razem wycofać.
Ten sposób poświęca aktualizacje w czasie rzeczywistym: nawet zmiana jednego zdania wymaga poczekania na build i wdrożenie. Ale na własnej stronie technicznej chętnie płacę to oczekiwanie za jeden system administracyjny mniej, jedną zależność w czasie wykonania mniej i łatwiejsze śledzenie zmian.
Kiedy to się sprawdza#
Jeśli jest jeden autor, częstotliwość publikacji akceptuje przebudowę, treść to głównie tekst, a Git jest już codziennym narzędziem, ta architektura bardzo dobrze pasuje. Krótkie wpisy obniżają próg pisania, artykuły zachowują pełny tok rozumowania, a wielojęzyczność sprawia, że ta sama treść trafia do większej liczby czytelników.
Jeśli wymagania zamienią się we współpracę wielu osób w czasie rzeczywistym, zarządzanie uprawnieniami, komentarze czytelników, logowanie lub subskrypcje, trzeba ponownie ocenić CMS i backend. Frontend-only nie sprawia, że te potrzeby znikają; po prostu na razie nie ma ich w zakresie tego bloga.
Zasada projektowa jest tu bardzo bliska temu, co opisałem w tekście Wzorzec projektowy architektury wielu układów i wielu motywów: najpierw wyraźnie rozdzielić odpowiedzialności, a potem pozwolić wspólnym regułom pilnować spójności.
Dla tej strony najważniejsze jest, żebym mógł pisać dalej i wiedzieć, co dokładnie wysyłam przy każdej publikacji.