Шаблоны отдельного сайта-потребителя
Сайт-потребитель превращает данные CMS в страницу. До этого мы обсуждали ответы REST отдельно; теперь соединим запрос, маршрут и HTML. Для «Редакционного дома» используем небольшой сервер Node без дополнительных зависимостей. Он показывает каталог и текст статьи, хранит token в окружении и не передаёт посетителю возможность выбрать редакционное состояние.
После урока подготовленный проект имеет два семейства адресов: каталог /ru/ или /en/ и документ /ru/articles/css-grid-cards. Это исходники для будущего локального запуска читателем. Сервер, страницы и HTTP-ответы при подготовке курса не выполнялись; ожидаемое поведение объясняется по файлам и контракту CMS.
Маршрут задаёт допустимый запрос
Сервер читает путь и принимает только ru/en и slug из маленьких латинских букв, цифр и дефисов. Так адрес становится набором понятных входных данных. Нельзя передать произвольный путь CMS через query и превратить наш потребитель в универсальный proxy. Доступ посетителя ограничен операциями, нужными библиотеке.
В каталоге page должен быть положительным целым числом не больше ста. Верхняя граница — учебная защита от заведомо бессмысленных запросов; она не описывает предел самой CMS. Запрос статьи использует page один и точный фильтр slug. При отсутствии ровно одного результата сервер отвечает 404. Такой ответ позволяет отличить отсутствующий документ от временной ошибки источника.
Обычные страницы вызывают readArticles из седьмого урока. Эта функция всегда устанавливает status published. Добавление ?status=draft в URL сайта ничего не меняет в запросе CMS. Не следует полагаться на то, что пользователь «обычно не знает» такого параметра: граница определяется кодом, а не скрытностью настройки.
Полный site/server.mjs содержит также закрытые маршруты preview и webhook из следующих уроков. На этом шаге не создавайте preview-ссылки и webhooks; обычный каталог не зависит от их использования.
import { createServer } from 'node:http';
import { readArticles, readDraft } from './cms.mjs';
import { articlePage, catalogue, layout } from './templates.mjs';
import { validPreview } from './preview.mjs';
import { acceptWebhook } from './webhook.mjs';
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (request.method === 'POST' && url.pathname === '/webhooks/strapi') {
await acceptWebhook(request, response);
return;
}
if (request.method !== 'GET') {
response.writeHead(405, { Allow: 'GET' }).end('Method not allowed');
return;
}
if (url.pathname === '/preview') {
if (!validPreview(url.searchParams)) {
response.writeHead(401).end('Invalid preview');
return;
}
const locale = url.searchParams.get('locale');
const { data } = await readDraft(url.searchParams.get('documentId'), locale);
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'private, no-store',
'X-Robots-Tag': 'noindex, nofollow',
'Referrer-Policy': 'no-referrer',
}).end(articlePage(data, locale, true));
return;
}
const route = /^\/(ru|en)\/(?:articles\/([a-z0-9-]+))?$/.exec(url.pathname);
if (!route) { response.writeHead(404).end('Not found'); return; }
const [, locale, slug] = route;
const requestedPage = Number(url.searchParams.get('page') || 1);
if (!Number.isInteger(requestedPage) || requestedPage < 1 || requestedPage > 100) {
response.writeHead(400).end('Invalid page');
return;
}
const { data, meta } = await readArticles({ locale, page: slug ? 1 : requestedPage, slug });
if (slug && data.length !== 1) { response.writeHead(404).end('Not found'); return; }
const html = slug ? articlePage(data[0], locale) : catalogue(data, meta.pagination, locale);
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
}).end(html);
} catch {
response.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8' })
.end(layout('Временно недоступно', '<h1>Библиотека временно недоступна</h1>'));
}
});
server.listen(Number(process.env.SITE_PORT || 4178), '127.0.0.1');
server.listen привязан к loopback. После вашей настройки окружения процесс сможет работать рядом с CMS на другом порту. У него собственная папка и собственный .env. Такое размещение помогает не перепутать server token с настройками административной панели. Не переносите .env в публичный assets каталог ради удобного копирования.
Простой renderer объясняет границу данных
В site/templates.mjs есть escape, layout, catalogue и articlePage. Escape заменяет символы, значимые для HTML. Строка <script> из body должна отображаться текстом, а не исполняться. Мы выбрали plain text field, поэтому такой подход согласуется с моделью. Для Rich Text/Blocks потребовался бы другой renderer с явной обработкой типов узлов.
Body разбивается по пустым строкам на абзацы. Это намеренно небольшой формат: здесь нет заголовков из Markdown, таблиц или встроенных media. Не вставляйте HTML и не ожидайте, что он автоматически станет разметкой. Более сложный редактор расширит и схему, и клиент; нельзя менять только тип поля, сохраняя прежний вывод.
Каталог строит список ссылок с title и summary. Slug кодируется при составлении адреса, текст экранируется при выводе. Эти операции решают разные задачи: кодирование части URL помогает передать значение как адрес, экранирование HTML помогает показать содержание как текст. Их нельзя заменять одной функцией, потому что контексты различаются.
В layout указан lang выбранной локали и viewport для маленького экрана. Страница использует один основной h1, а title документа находится в head. Это минимальные свойства понятного HTML, а не законченная дизайн-система. Архив содержит полный шаблон, поэтому его можно расширить последовательно, видя, где появляется каждое новое значение.
ReadArticles уже запрашивает cover, seo и resources, однако минимальный renderer их не показывает. Это заявленная граница. Данные нужны для обсуждения populate, но сам факт получения не означает готовую обложку или SEO-настройки. Для расширения сначала разрешите относительные media URL относительно CMS, проверьте допустимые схемы resources и определите fallback SEO-title. Затем добавляйте соответствующие элементы HTML.
Ошибка источника и отсутствие статьи
Если CMS вернула ошибку или не ответила за timeout, сервер показывает сообщение «Библиотека временно недоступна» с HTTP 503. Посетитель не получает детали Authorization или сырой ответ CMS. Это помогает сохранить ясный интерфейс и не раскрыть внутренние сведения. В дальнейшей эксплуатации понадобится серверный журнал с безопасной диагностикой; здесь он не реализован.
Не превращайте любую ошибку CMS в пустой каталог. Пустой data означает успешный запрос без документов, а exception означает невозможность получить предполагаемый ответ. Для пользователя эти ситуации имеют разный смысл. В первой можно объяснить отсутствие опубликованных материалов, во второй — предложить повторить посещение позже.
При пустом data каталог пока показывает пустой список. Это ещё одна небольшая граница минимальной реализации. Для самостоятельного расширения добавьте осмысленную фразу о том, что материалов в выбранной локали пока нет. Не скрывайте текстом пустого каталога ошибку сети: условие должно опираться на успешный REST-ответ, а не ловить все исключения сразу.
После снятия публикации первой статьи запрос по её slug должен дать 404. После изменения только draft обычная страница должна сохранить прежний published-текст. Эти два ожидаемых результата связывают сервер с редакционным процессом восьмого урока. Проверяйте их на одной статье, иначе различие записей легко принять за работу версий.
Ещё одна возможная ошибка — строить ссылку страницы из title вместо slug. Заголовок может содержать пробелы, кириллицу и исправляться редактором. Такой маршрут перестанет соответствовать фильтру CMS после обычной правки текста. Наш template берёт slug, а title использует только как видимую подпись. Это не запрещает русские URL другим проектам; оно сохраняет конкретный контракт выбранной лаборатории. Если решите поменять схему адресов, измените router, допустимые значения и правила перехода вместе, а существующие опубликованные ссылки рассмотрите отдельно.
Файлы клиента используют документированный REST populate/select, но самостоятельный сервер — авторский учебный пример. Он не содержит production cache, подключения CDN, мониторинга и внешнего TLS. Отсутствие зависимостей упрощает чтение, но не превращает несколько файлов в готовый сервис для большой редакции. Результат урока конкретнее: маршрут выбирает опубликованное представление, сервер применяет закрытый token, а renderer безопасно отображает обычный текст. В следующем шаге создадим отдельный канал для редакторского preview.