Lingo.dev CLI переводит файлы Markdoc и JSON-каталоги строк интерфейса через настроенный движок локализации. Markdoc — это формат для работы с контентом на базе Markdown с типизированными пользовательскими тегами на React — отличный выбор для сайтов на Next.js App Router, где длинные тексты сочетаются с интерактивными компонентами.
В этом руководстве — полная локализация сайта на Next.js App Router: настройка CLI, организация контента по локалям, рендеринг Markdoc в динамических маршрутах и автоматизация переводов с помощью Lingo.dev GitHub App.
Демо-репозиторий
Чтобы следовать руководству, клонируйте или форкните lingodotdev/markdoc-nextjs-localization-example. В репозитории есть рабочее приложение на Next.js App Router с контентом Markdoc, конфигурацией Lingo.dev CLI и рабочим процессом CI.
Как работает локализация в Next.js + Markdoc#
В большинстве сайтов на Next.js App Router локализованный контент делится на два слоя:
| Слой | Что здесь хранится | Пример файла |
|---|---|---|
| Длинные тексты | Маркетинговые страницы, документация, посты в блоге | src/content/en/pages/home.md |
| Строки интерфейса | Подписи в навигации, CTA, состояния кнопок | src/content/en/ui.json |
Маршруты находятся в src/app/[lang]/ и при каждом запросе читают файлы нужной локали. Middleware определяет локаль по умолчанию из заголовка браузера Accept-Language и перенаправляет пути без префикса, например /, на /en (или на наиболее подходящий вариант).
CLI разбирает файлы Markdoc с frontmatter и пользовательскими тегами, а каталог строк интерфейса обрабатывает как JSON. И то, и другое переводит дельту через движок локализации и записывает файлы для каждой локали рядом с исходниками.
Что понадобится#
Создайте движок локализации
Каждый запуск CLI прогоняет контент через движок локализации — это конфигурация, которая задаёт модель LLM, глоссарий, тональность бренда и правила. Создайте его в панели управления Lingo.dev и получите API-ключ для CI.
Проверьте Node.js
Для CLI нужен Node.js 22 или выше:
node -vНастройте проект Next.js
В проекте нужен App Router (src/app/) и каталог контента с разбивкой по локалям. В демо-репозитории — по одному каталогу на локаль внутри src/content/ (например src/content/en/) с двумя подпапками (pages/ и blog/) и файлом ui.json. Основы маршрутизации — в документации по интернационализации Next.js.
Организуйте контент#
Разделяйте контент по назначению. Длинные страницы и посты создаются в Markdoc, а короткие строки интерфейса хранятся в JSON, чтобы компоненты могли загружать их напрямую.
src/content/
en/ # Source locale
pages/home.md # Long-form Markdoc
blog/hello.md
ui.json # UI strings (navbar, CTAs, button states)
es/ # Target locales – generated by Lingo.dev
fr/
de/Файлы Markdoc поддерживают frontmatter для метаданных страницы (title, description, date, author) и пользовательские теги, которые рендерятся как React-компоненты. Вот как выглядит минимальная страница:
---
title: Author once in Markdoc, ship in every language.
description: An example Next.js App Router app that localizes Markdoc with Lingo.
---
{% inline-callout type="info" %}
This page is authored in Markdoc and translated by Lingo.dev.
{% /inline-callout %}
## Built from three pieces
Markdoc custom tags render as React components – even interactive ones.Настройте CLI#
Установите CLI и войдите в систему:
npm install -g @lingo.dev/cli
lingo loginЗатем создайте конфиг и привяжите его к движку:
lingo init
lingo linklingo init создаёт .lingo/config.json с исходной и целевыми локалями и шаблонами файлов для перевода; lingo link добавляет orgId и engineId. Зафиксируйте .lingo/config.json в репозитории — тогда все участники команды и запуски CI будут работать с одной конфигурацией.
Для этого проекта в конфиге объявлены два шаблона файлов — один для контента Markdoc, другой для каталога строк интерфейса:
{
"orgId": "org_...",
"engineId": "eng_...",
"sourceLocale": "en",
"targetLocales": ["es", "fr", "de"],
"files": [
{ "pattern": "src/content/en/pages/*.md" },
{ "pattern": "src/content/en/blog/*.md" },
{ "pattern": "src/content/en/ui.json" }
]
}Сегмент локали в каждом пути подставляется для каждой целевой локали: src/content/en/pages/home.md становится src/content/es/pages/home.md, а src/content/en/ui.json — src/content/de/ui.json. Исходный путь должен содержать код локали. Форматы определяются автоматически по расширению файла, поэтому для файлов Markdoc (.md) и JSON (.json) явно указывать тип не нужно. Подробности — в разделах Конфигурация и Форматы.
Каталоги в одном файле
Новый CLI ожидает один файл на локаль с кодом локали в пути (как показано выше). Если строки интерфейса хранятся в одном многолокальном JSON-файле, такой формат (прежний бакет json-per-locale) новым CLI пока не поддерживается — оставьте его на устаревшем CLI и следите за журналом изменений. Рекомендуемый подход — разбить на отдельные файлы, по одному на локаль.
Рендеринг Markdoc в App Router#
Обычно динамический маршрут загружает документ и рендерит преобразованное дерево. В демо-репозитории для этого есть небольшой helper:
// src/lib/markdoc.ts
export async function loadDoc(
locale: Locale,
collection: "pages" | "blog",
slug: string,
) {
const raw = await fs.readFile(
path.join(process.cwd(), "src/content", locale, collection, `${slug}.md`),
"utf8",
);
const ast = Markdoc.parse(raw);
const frontmatter = ast.attributes.frontmatter
? parseFrontmatter(ast.attributes.frontmatter)
: {};
const content = Markdoc.transform(ast, { ...schema, variables: { frontmatter } });
return { frontmatter, content };
}Страница App Router — это тонкая обёртка, которая связывает документ со строками интерфейса для конкретной локали:
// src/app/[lang]/page.tsx
export default async function Home({ params }: PageProps<"/[lang]">) {
const { lang } = await params;
const doc = await loadDoc(lang, "pages", "home");
const { home } = await getMessages(lang);
return (
<main>
<h1>{doc.frontmatter.title}</h1>
{renderMarkdoc(doc.content)}
</main>
);
}Пользовательские теги Markdoc (callout, bento, blog-hero и т. д.) объявляются в markdoc.schema.ts и подключаются к React-компонентам из src/components/markdoc/. Полное API смотрите в документации по схеме Markdoc.
Определяйте локаль в middleware#
Middleware в Next.js анализирует запрос до рендеринга маршрута. Используйте его, чтобы перенаправлять пути без префикса на наиболее подходящую локаль на основе заголовка Accept-Language:
// src/middleware.ts
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
);
if (hasLocale) return;
const locale = pickLocale(request); // parses Accept-Language
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;
return NextResponse.redirect(url);
}
export const config = {
matcher: ["/((?!_next|api|.*\\..*).*)", ],
};Пользователи попадают на /en, /es, /fr или /de, даже если не вводят префикс вручную.
Переводите локально#
После lingo login выполните push. При первом запуске — или после добавления новой целевой локали — переведите всё с нуля:
lingo push --backfill-missingВ последующих запусках достаточно отправить дельту:
lingo pushlingo push читает все файлы, соответствующие шаблонам, определяет непереведённые записи с помощью файла блокировки (.lingo/lock.json, зафиксированного в репозитории), переводит дельту через движок локализации, ожидает завершения и записывает результаты в каталог каждой целевой локали. Ключи frontmatter, пользовательские теги Markdoc и структуры JSON сохраняются — меняется только переводимый текст. Чтобы забрать переводы, созданные в другом месте (например в CI), выполните lingo pull.
Чтобы ограничить запуск конкретными файлами, передайте glob:
lingo push "src/content/en/blog/*.md"Автоматизация в CI#
Установите Lingo.dev GitHub App и укажите ваш репозиторий. Приложение читает .lingo/config.json и привязанный engineId на стороне сервера и открывает pull request с переводами при каждом изменении исходного контента — никаких файлов рабочего процесса, runner'а, секретов с API-ключом и хлопот с файлом блокировки.
Проверяйте перед деплоем#
Используйте lingo check как условие деплоя — так непереведённый контент не попадёт в production. Команда завершается с ненулевым кодом, если какие-то записи ещё не переведены:
lingo checkДобавьте это как отдельный шаг CI перед сборкой Next.js:
- name: Verify translations
run: lingo check
env:
LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }}
- name: Build
run: pnpm build