Статьи
Многоязычный блог «заметки + статьи» на чистом фронтенде
Я превратил свой сайт в многоязычный блог «заметки + статьи»: контент хранится в 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 используют один и тот же конвейер, что уменьшает разрыв вида «локально работает, а в проде нет».
Теги принимаются только те, чьи ключи зарегистрированы в tags.yaml; статьи и другие заметки, на которые ссылается заметка, должны существовать; локальный предпросмотр может читать черновики, а production-сборка их исключает. Эти правила держатся не на том, что я не забуду их при публикации: проверка реально блокирует ошибку.
У вывода Markdown тоже есть чёткая граница: сырой HTML напрямую не выводится, сгенерированный HTML проходит очистку, а подсветка блоков кода выполняется при сборке. Изображения контента с локальными относительными путями конвертируются в WebP шириной не более 1600px, из них удаляется EXIF, включая GPS; статья, карточки и превью для шаринга используют одно и то же обработанное изображение. Удалённые изображения и изображения с абсолютными путями от корня эту локальную конвертацию не проходят.
Стоимость многоязычности платится при публикации#
Сейчас сайт поддерживает 22 языка. Исходный текст на традиционном китайском пишется один раз, а заголовки, описания, основной текст и подписи к изображениям на остальных 21 языке сохраняются в соседнем locales.yaml.
Здесь есть решение, которое для меня важно: читателю не нужно ждать перевода. content:localize создаёт переводы во время публикации, а production-сборка только читает уже существующие файлы и не обращается к сервису перевода при каждом деплое.
Исходник и перевод сопоставляются через source digest. Если исходник изменился, старый перевод больше нельзя считать актуальным; если не хватает какого-то языка или перевод устарел, content:check завершится ошибкой. В процессе перевода код, inline code и целевые адреса ссылок защищены, чтобы технические идентификаторы и URL не переводились вместе с текстом.
Это не значит, что автоматический перевод по качеству равен работе редактора. Он решает вопрос полноты и согласованности обновлений, а терминология и тон по-прежнему нуждаются в вычитке. Для неизменённого исходника существующий перевод сохраняется, а текст, исправленный вручную, не будет перезаписан при повторном запуске.
В списках читаем краткое описание, статья загружается по клику#
Когда контента становится больше, то если главная страница сразу загружает полный текст каждой статьи на каждом языке, даже чистый фронтенд будет тормозить.
Плагин Vite делит контент на три точки входа: virtual:content/latest для главной, virtual:content/index для страниц контента и virtual:content/article/<slug> отдельно для каждой статьи. На главной берутся три последние заметки и три карточки статей; данные заметок по-прежнему включают полный текст и переводы, просто на карточке он обрезается. Карточка длинной статьи несёт только краткое описание и подобные данные, а полный текст подгружается для каждой статьи отдельно через dynamic import.
Ожидание основного текста берёт на себя Suspense в React, а внешний error boundary показывает сообщение об ошибке загрузки. Если запрос основного текста не удался, заголовок, краткое описание и навигация по странице остаются на месте.
Здесь тоже есть компромисс: сейчас на каждую статью приходится один chunk с основным текстом, в котором собраны переводы на все языки; разбивки до одного chunk на язык пока нет. Полные тексты заметок и их переводы попадают в индекс контента. По мере роста объёма контента следующее, что нужно измерять, — размер индекса и объём загрузки многоязычного основного текста.
Нужны и переключение на фронтенде, и точка входа для статического чтения#
Если делать только 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 и бэкенд. Чистый фронтенд эти требования не отменяет, просто сейчас я не включил их в рамки этого блога.
Принцип этого дизайна очень близок к тому, о чём я писал в статье Паттерны архитектуры для нескольких макетов и тем: сначала чётко разделить ответственность, а затем пусть общие правила обеспечивают согласованность.
Для этого сайта главное — чтобы я мог продолжать писать и знать, какой именно контент уходит с каждой публикацией.