Даём каталогу два языка из одной записи

Прошлая часть закончилась обещанием: у обложки есть подпись alt_text, и она локализована — карта {ru, en}. Одна картинка, две подписи. Пришло время сделать двуязычным весь каталог: библиотекой пользуются и на русском, и на английском, и описание книги должно существовать на обоих языках.
Лобовое решение — завести вторую запись. «Мастер и Маргарита» рядом с «The Master and Margarita», у каждой свой автор, год и статус. Получаются два каталога в одной таблице. Книга «на руках» в русской половине и отсутствует в английской, обложка загружена дважды, поиск находит дубли. Вторая запись — это не перевод, а вторая копия данных, которая будет жить своей жизнью.
Другой способ — хранить оба языка в одной записи, а платформа уже умеет именно это. В этой части разбираем локализованные поля, локаль запроса и переключатель языка в нашей админке. Предыдущие части цикла: сборка закрытой админки, читательские аккаунты, фильтры и честные ошибки, обложки из медиатеки.
Языки проекта
Сначала проекту называют его языки. В «Настройках» есть раздел «Локализация»: таблица локалей, у одной из них переключатель «Локаль по умолчанию», внизу — поле «Добавить».

У демо-проекта две локали — ru и en, по умолчанию русская. Дефолт здесь не украшение. Он решает, куда ляжет одиночная строка при записи в локализованное поле и какой язык получит читатель, который никакой локали не просил. Изначально дефолтом стояла английская локаль — мы переключили её на русскую, прежде чем локализовать поле, чтобы строки без карты попадали в русский текст.
Коды локалей проверяются по списку ISO (en, ru, de, трёхбуквенные тоже можно), и локаль по умолчанию обязана входить в список проектных. Код с регионом вроде ru-RU не пройдёт — список принимает чистые коды языков.
Поле, которое хранит карту
Теперь поле. В редакторе типа book добавляем description с типом «Текст» и флажком «Локализованное поле»:

Схема перестраивается, и внутри каждой записи description становится картой языков:
{
"description": {
"ru": "Роман о визите дьявола в атеистическую Москву 1930-х годов.",
"en": "A novel about the devil's visit to atheist Moscow."
}
}
Правил записи три, и они короткие.
Пришла строка — она ложится в локаль по умолчанию. Соседние языки платформа сохраняет. Частичная правка одной строки не стирает остальную карту.
Пришла карта — она заменяет карту целиком. Это главная мина механизма. Отправить {en: "..."} без ключа ru значит стереть русский текст одним запросом. Форма, которую мы соберём ниже, всегда шлёт оба ключа.
Ключ вне списка проектных локалей — запись отклоняется. {fr: "..."} в проекте с локалями ru и en не сохранится: сначала французский добавляют в список языков, потом пишут текст.
Локализовать можно не всякое поле: строка, текст, дата, число, ссылка на запись — можно; пароль и полиморфные блоки — нет.
Два таба вместо одного поля
В редакторе записи локализованное поле разворачивается в табы — по одному на каждую локаль проекта:

Пустой таб — не ошибка, а честное «перевода ещё нет». Тот же вид имеет alt_text у обложки из прошлой части: он тоже локализован, просто мы тогда смотрели на него как на подпись, а не как на механику.
Локаль запроса
Язык ответа платформа выбирает по локали запроса — её передают параметром ?locale= или заголовком X-Locale. Локализованное поле в ответе — скаляр этой локали. Запрошенной локали в карте нет — платформа откатывается на локаль по умолчанию. Нет и её — поле придёт пустым. Мусор в ?locale= ошибкой не становится: незнакомый код считается отсутствующей локалью, сработает тот же откат.
Скаляр и карта доступны одновременно. В одном запросе можно попросить и то и другое:
{
"query": "{ books(limit: 1) { description description_locales } }"
}
description придёт строкой на языке запроса, description_locales — строкой JSON с полной картой. Парное поле только для чтения; в мутациях, фильтрах и сортировках его нет.
И здесь нюанс, на котором держится вся эта часть. GraphQL-действие внутри сценария ходит на схему с сервисным ключом — и локаль не передаёт. Никакого заголовка в действии не настроить. Скаляр в сценарии — всегда локаль по умолчанию, какая бы она ни была у посетителя сайта. Получается, языком в своей админке распоряжается ваш код. Сценарию отдаём карту, язык выбираем на клиенте.
Сценарий отдаёт карту
Списку книг карта нужна целиком, поэтому books_list просит оба поля:
{
"query": "query($status: String, $q: String) {\n books(limit: 5, offset: {{ offset }}, orderBy: [{field: {{ field }}, direction: DESC}], search: $q, where: {status: {eq: $status}}) {\n id title author year status\n description description_locales\n cover { id url }\n }\n booksCount(search: $q, where: {status: {eq: $status}})\n}"
}
Фильтры, поиск и пагинация из третьей части не тронуты — они работают по title, author и status, а эти поля мы не локализовали. Локализация описания не сломала ни один существующий эндпоинт.
Добавление книги теперь принимает описание картой. Тип переменной — JSON, он принимает настоящий объект:
{
"query": "mutation($title: String!, $author: String!, $year: Int, $status: String, $cover: ID, $description: JSON) {\n createBook(input: {title: $title, author: $author, year: $year, status: $status, cover: $cover, description: $description}) {\n id title author year status\n description description_locales\n cover { id url }\n }\n}",
"variables": {
"description": {
"ru": "{{json.description.ru}}",
"en": "{{json.description.en}}"
}
}
}
Переменные в сценарии можно вкладывать картой — движок передаст её в мутацию как объект, а Liquid отрендерит каждую строку по отдельности. Из тела запроса берутся ровно два ключа — json.description.ru и json.description.en.
Пустая строка в карте — нормальный случай: библиотекарь добавил книгу, а перевод напишет потом. Она означает «перевода нет», и решение, что показывать вместо него, принимает клиент.

Переключатель в своей админке
В админке появляется пара кнопок RU и EN:

Язык хранится в localStorage, переключение перерисовывает описания на месте, без перезапроса, потому что карта уже приехала в каждом элементе списка. Выбор языка — та же цепочка откатов, что и на платформе: выбранная локаль, затем скаляр, затем пусто:
function descOf(b: Book): string {
let loc: Record<string, string> = {};
try {
loc = JSON.parse(b.description_locales || "{}");
} catch {
/* карта не распарсилась — остаёмся на скаляре */
}
return loc[lang.value] || b.description || "";
}
b.description здесь — скаляр из ответа сценария, то есть язык по умолчанию. Он подхватывается, когда у поля нет карты вовсе или карта пуста.
Форма добавления получает два поля описания, и тело запроса всегда шлёт оба ключа — то самое правило «карта заменяет карту», соблюдённое на клиенте:
await fetch("/api/books/add", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
title: form.value.title,
author: form.value.author,
year: form.value.year ? Number(form.value.year) : null,
cover: form.value.cover || null,
description: {
ru: form.value.descriptionRu.trim(),
en: form.value.descriptionEn.trim(),
},
}),
});
Оба описания необязательны. Пустые строки доедут до карты и останутся пустыми. Перевод можно дописать позже — в табах записи или той же формой.

Переведён не только контент, но и оболочка: тот же приём — словарь строк и выбор на клиенте — покрывает заголовки, кнопки и фильтры. Отличие в объёме словаря, а не в механике. Описания при этом живут по своей цепочке откатов: карта локалей, затем скаляр, затем пусто.
Что осталось за кадром
Поля локализуются по одному, и для каталога этого хватило. За бортом остался вопрос, где админке брать данные извне — например, подтянуть курс валют или отправить книгу в сторонний сервис учёта. Это http_post, подписи и секреты, и им посвящена следующая часть цикла.
Все .dynflow.json этой части лежат в ветке step-8-locale, применяются командой flowctl apply flows/books_add.dynflow.json --publish.
Все записи