Статті
Багатомовний блог «стрічка + статті» на чистому фронтенді
Я зробив свій сайт багатомовним блогом «стрічка + статті»: контент керується через Markdown, переклад виконується під час публікації, статичні сторінки генерує збірка, а браузер відповідає за читання та перемикання мови. У статті розбираю модель контенту, спільний конвеєр, завантаження на вимогу, компроміси щодо SEO і те, що саме економить чистий фронтенд.
· Оновлено · 1 хв читання
Я хотів, щоб на моєму сайті можна було і швидко записати думку, і докладно описати технічну проблему. Короткі тексти йдуть у «стрічку», довгі — у «статті», вони можуть посилатися одне на одного, а коли відвідувач перемикає мову, увесь контент перемикається разом із нею.
Така вимога здається звичайною, але якщо з самого початку додати базу даних, адмінку, API для контенту і ще обробляти версії для кожної мови, дуже швидко вийде черговий продукт, який потрібно постійно підтримувати.
Я вирішив спочатку звузити вимоги до того, чим справді користуюся: один автор, контент у Git, перебудова сайту після публікації, а для читання потрібні лише статичні файли й браузер. Це і є нинішня архітектура «стрічка + статті» на stephen.taipei.
Чистий фронтенд — це про сторону читання#
Одразу уточню межі. Проєкт використовує React, Vite і Tailwind; обробка контенту, перетворення зображень і скрипти збірки як і раніше потребують Node.js, а переклад під час публікації звертається до зовнішнього сервісу. Після розгортання статичні файли віддає Nginx.
Тож «чистий фронтенд» для мене означає, що коли читач відкриває сайт, йому не потрібен окремий бекенд блогу, щоб діставати дані, збирати сторінки чи перекладати на льоту. Збірка є, сервер є, але немає постійно запущеного прикладного API чи бази даних лише заради читання статей.
Коли роботу виконано заздалегідь, сторона читання стає значно простішою.
Стрічка і статті: спільне джерело, але кожна зі своєю моделлю#
Увесь контент лежить у content/, а оригінали — це Markdown. Два типи контенту я не став силоміць зводити до одного формату:
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
Запис стрічки — це коротка картка. Назва файлу — хвилина за тайбейським часом, вона ж слугує id. Запис можна позначити як думку, щось вивчене, запуск, граблі або прочитане. Достатньо одного абзацу тексту, не обов'язково щоразу вигадувати заголовок. Понад 500 символів — з'являється нагадування, понад 1000 — це вже час писати статтю.
Стаття натомість має сталий slug, заголовок, анотацію, дату, а також може мати обкладинку й час оновлення. Після публікації адреса більше не змінюється, щоб уже поширені посилання не ламалися.
Між ними працює зв'язок article:. Запис стрічки може спершу зафіксувати проблему, а потім послатися на повну статтю; сторінка статті, у свою чергу, показує список записів, які на неї посилаються. Короткий запис зберігає відправну точку моменту, а довгий текст доводить міркування до кінця, і не потрібно вставляти той самий повний текст в обидва місця.
Нехай правила контенту зупиняють помилки ще до збірки#
Якщо Markdown просто викидати на екран, багато проблем буває видно лише після запуску: помилка в даті, стаття, до якої не можна дістатися, зображення без alt або одна тема, записана трьома схожими тегами.
Ці правила я заклав у спільний конвеєр контенту. loadContent читає файли, перевіряє поля через Zod, а потім обробляє Markdown, зображення, мови та зв'язки між матеріалами. І перевірка в CLI, і збірка Vite користуються цим самим конвеєром, що зменшує розбіжність на кшталт «локально працює, а в продакшні — ні».
Теги приймаються лише ті, чиї key зареєстровано в tags.yaml; статті та інші записи, на які посилається стрічка, мають існувати; локальний перегляд може читати чернетки, а бойова збірка їх виключає. Ці правила тримаються не на тому, що я пам'ятаю про них під час публікації, а на тому, що перевірка справді зупиняє збірку.
Для Markdown також чітко визначено межі виводу: сирий HTML не виводиться напряму, згенерований HTML проходить очищення, а підсвічування блоків коду виконується під час збірки. Зображення в контенті з локальними відносними шляхами перетворюються на WebP шириною до 1600px, з видаленням EXIF, зокрема GPS; стаття, картка й прев'ю для поширення використовують те саме оброблене зображення. Віддалені зображення та зображення з абсолютними шляхами від кореня цей локальний процес перетворення оминають.
Вартість багатомовності сплачується під час публікації#
Зараз сайт підтримує 22 мови. Оригінал традиційною китайською пишеться один раз, а заголовки, анотації, основний текст і описи зображень для решти 21 мови зберігаються поруч у locales.yaml.
Тут є рішення, яке для мене принципове: читач не чекає на переклад. content:localize створює переклади під час публікації, а бойова збірка лише читає вже наявні файли й не звертається до сервісу перекладу при кожному розгортанні.
Оригінал і переклад зіставляються через source digest. Якщо оригінал змінився, старий переклад більше не можна вважати актуальним; якщо бракує якоїсь мови або переклад застарів, content:check завершується помилкою. Під час перекладу код, inline code і цілі посилань захищаються, щоб технічні ідентифікатори й URL не були перекладені разом з рештою.
Це не означає, що автоматичний переклад дорівнює якості редагування людиною. Він вирішує питання повноти та узгодженості оновлень, а професійна лексика й тон усе одно потребують вичитки. Для незмінених оригіналів наявні переклади зберігаються, а текст, виправлений вручну, не перезаписується повторним запуском.
У списках читаємо анотацію, а статтю завантажуємо лише після переходу#
Коли контенту побільшає, то якщо головна сторінка одразу завантажуватиме повний текст кожної статті кожною мовою, навіть чистий фронтенд працюватиме повільно.
Плагін Vite розділяє контент на три точки входу: virtual:content/latest для головної, virtual:content/index для сторінок контенту та окремий virtual:content/article/<slug> для кожної статті. На головній беруться три найновіші записи стрічки й три картки статей; дані записів стрічки все ще містять повний текст і переклади, просто на картці вони обрізаються. Картка довгої статті несе лише анотацію та подібні дані, а повний основний текст підвантажується для кожної статті окремо через dynamic import.
За очікування основного тексту відповідає Suspense у React, а зовнішня межа помилок показує повідомлення, якщо завантаження не вдалося. Якщо запит основного тексту провалився, заголовок, анотація та навігація сторінкою залишаються на місці.
Тут теж є компроміс: нині одна стаття — це один чанк основного тексту, у якому лежать переклади всіма мовами, а до чанка на кожну мову справа ще не дійшла. Повний текст записів стрічки та їхні переклади потрапляють в індекс контенту. Коли контенту ставатиме дедалі більше, наступне, що треба заміряти, — розмір індексу та обсяг завантаження багатомовного основного тексту.
Окрім перемикання на фронтенді, потрібен і статичний вхід для читання#
Якщо зробити лише SPA, то при поширенні посилання часто видно тільки заголовок і зображення головної сторінки. Для статей цього недостатньо.
Тому під час збірки кожна адреса запису стрічки, статті й тегу отримує власну HTML-оболонку, у якій прописано title, description, canonical, Open Graph і JSON-LD, а в noscript розміщено читабельний текст цією мовою. Навіть без виконання JavaScript можна отримати вміст статті та її окрему обкладинку.
З тієї самої контентної бази також створюються feed.xml, sitemap-content.xml, Markdown-версії статей та індекс статей для llms.txt. Усе це артефакти збірки, а не ще один набір контенту, який треба синхронізувати вручну.
Щодо багатомовного SEO теж скажу прямо: записи стрічки, статті й теги мають власні адреси в кожній мові. Для zh-TW використовується /articles/<slug>, для решти мов додається префікс, наприклад /en/articles/<slug>. Під час збірки для кожної мови окремо виводяться статичні сторінки з self-canonical і hreflang, і всі вони внесені до sitemap-content.xml; feed і Markdown-версії залишаються мовою оригіналу.
Головна сторінка натомість має одну адресу й віддає попередньо відрендерені версії залежно від мови браузера та світлого чи темного режиму. Усі 22 мовні версії кожного матеріалу — це окремі сторінки, які пошукові системи можуть індексувати самостійно.
Публікація — теж конвеєр доставки#
Мій процес публікації такий: написати оригінал, згенерувати переклади, перевірити контент, закомітити в Git і злити в main. Наявні GitHub Actions перебудовують сайт і розгортають його.
npm run content:localize
npm run content:check
npm run test:content
npm run build
Якщо мови подано не повністю, спершу це виправляємо. Якщо стаття є чернеткою, вона залишається в локальному перегляді. Для бойового виводу додатково перевіряється, що сторінок чернеток немає, а також скануються основні публічні виводи — feed, sitemap, головна сторінка та бандл JavaScript — на ознаки чернеток. Контент, як і код, має diff, має версії й може відкочуватися разом з ним.
Цей підхід жертвує оперативним оновленням: щоб змінити один абзац, теж доводиться чекати збірки й розгортання. Але для власного технічного сайту я готовий платити таким очікуванням, щоб мати на одну адмінку менше, на одну залежність під час виконання менше та легше відстежувану історію змін.
Коли це доречно#
Якщо автор один, частота публікацій дозволяє перебудови, контент здебільшого текстовий і Git уже звичний, ця архітектура дуже підходить. Короткі записи знижують поріг для письма, статті зберігають повні міркування, а багатомовність дає змогу більшій кількості людей читати той самий матеріал.
Якщо ж вимоги перетворяться на спільну роботу в реальному часі для кількох людей, керування правами, коментарі читачів, вхід чи підписку, CMS і бекенд доведеться переоцінювати. Чистий фронтенд не змушує ці вимоги зникнути, просто зараз я не включаю їх у межі цього блогу.
Принцип цього дизайну дуже близький до того, що я писав у статті Патерни архітектури для кількох макетів і тем: спершу чітко розділити відповідальність, а потім нехай спільні правила стежать за узгодженістю.
Для цього сайту головне — щоб я міг писати й далі та знав, що саме надсилається з кожною публікацією.