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

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

Прошлая часть закончилась обещанием: у обложки есть подпись alt_text, и она локализована — карта {ru, en}. Одна картинка, две подписи. Пришло время сделать двуязычным весь каталог: библиотекой пользуются и на русском, и на английском, и описание книги должно существовать на обоих языках.

Лобовое решение — завести вторую запись. «Мастер и Маргарита» рядом с «The Master and Margarita», у каждой свой автор, год и статус. Получаются два каталога в одной таблице. Книга «на руках» в русской половине и отсутствует в английской, обложка загружена дважды, поиск находит дубли. Вторая запись — это не перевод, а вторая копия данных, которая будет жить своей жизнью.

Другой способ — хранить оба языка в одной записи, а платформа уже умеет именно это. В этой части разбираем локализованные поля, локаль запроса и переключатель языка в нашей админке. Предыдущие части цикла: сборка закрытой админки, читательские аккаунты, фильтры и честные ошибки, обложки из медиатеки.

Языки проекта

Сначала проекту называют его языки. В «Настройках» есть раздел «Локализация»: таблица локалей, у одной из них переключатель «Локаль по умолчанию», внизу — поле «Добавить».

Настройки проекта, раздел «Локализация»: таблица с локалью en и локалью ru с бейджем «По умолчанию», внизу поле добавления локали

У демо-проекта две локали — ru и en, по умолчанию русская. Дефолт здесь не украшение. Он решает, куда ляжет одиночная строка при записи в локализованное поле и какой язык получит читатель, который никакой локали не просил. Изначально дефолтом стояла английская локаль — мы переключили её на русскую, прежде чем локализовать поле, чтобы строки без карты попадали в русский текст.

Коды локалей проверяются по списку ISO (en, ru, de, трёхбуквенные тоже можно), и локаль по умолчанию обязана входить в список проектных. Код с регионом вроде ru-RU не пройдёт — список принимает чистые коды языков.

Поле, которое хранит карту

Теперь поле. В редакторе типа book добавляем description с типом «Текст» и флажком «Локализованное поле»:

Редактор типа «Книга»: список полей — title, author, year, status, cover и новое поле description с бейджем «Локализованное поле», ниже форма добавления поля с тем же флажком

Схема перестраивается, и внутри каждой записи description становится картой языков:

{
  "description": {
    "ru": "Роман о визите дьявола в атеистическую Москву 1930-х годов.",
    "en": "A novel about the devil's visit to atheist Moscow."
  }
}

Правил записи три, и они короткие.

Пришла строка — она ложится в локаль по умолчанию. Соседние языки платформа сохраняет. Частичная правка одной строки не стирает остальную карту.

Пришла карта — она заменяет карту целиком. Это главная мина механизма. Отправить {en: "..."} без ключа ru значит стереть русский текст одним запросом. Форма, которую мы соберём ниже, всегда шлёт оба ключа.

Ключ вне списка проектных локалей — запись отклоняется. {fr: "..."} в проекте с локалями ru и en не сохранится: сначала французский добавляют в список языков, потом пишут текст.

Локализовать можно не всякое поле: строка, текст, дата, число, ссылка на запись — можно; пароль и полиморфные блоки — нет.

Два таба вместо одного поля

В редакторе записи локализованное поле разворачивается в табы — по одному на каждую локаль проекта:

Редактор записи книги: поле description с табами en и ru, активна русская вкладка с текстом описания, выше поля status и cover

Пустой таб — не ошибка, а честное «перевода ещё нет». Тот же вид имеет 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.

Пустая строка в карте — нормальный случай: библиотекарь добавил книгу, а перевод напишет потом. Она означает «перевода нет», и решение, что показывать вместо него, принимает клиент.

Редактор сценария books_add: слева граф условий, справа панель GraphQL-узла с мутацией на $description: JSON и переменными, где description — карта с ключами en и ru

Переключатель в своей админке

В админке появляется пара кнопок RU и EN:

Каталог книг в админке библиотеки: включён режим RU, под каждым названием русское описание, в форме поля «Описание (RU)» и «Description (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(),
    },
  }),
});

Оба описания необязательны. Пустые строки доедут до карты и останутся пустыми. Перевод можно дописать позже — в табах записи или той же формой.

Тот же каталог в режиме EN: интерфейс переведён целиком — заголовки, кнопки, фильтры и статусы, активна кнопка EN

Переведён не только контент, но и оболочка: тот же приём — словарь строк и выбор на клиенте — покрывает заголовки, кнопки и фильтры. Отличие в объёме словаря, а не в механике. Описания при этом живут по своей цепочке откатов: карта локалей, затем скаляр, затем пусто.

Что осталось за кадром

Поля локализуются по одному, и для каталога этого хватило. За бортом остался вопрос, где админке брать данные извне — например, подтянуть курс валют или отправить книгу в сторонний сервис учёта. Это http_post, подписи и секреты, и им посвящена следующая часть цикла.

Все .dynflow.json этой части лежат в ветке step-8-locale, применяются командой flowctl apply flows/books_add.dynflow.json --publish.


Все записи