Multilingual Engine — небольшой плагин, который добавляет к сайту на WordPress языковые версии статей через префикс в URL (/en/, /de/) и не тянет за собой ни одной сторонней библиотеки. Язык каждой записи хранится в служебной таксономии, а не в метаполе, поэтому выборки остаются быстрыми даже на тысячах записей. Ниже — как это устроено внутри, как включить за десять минут и на каких местах спотыкаются чаще всего.

Материал написан по исходникам плагина версии 1.09.5 (файл MultilingualEngine.php), а не по памяти: всё, что описано, можно проверить в коде. Если вы ждёте «просто ещё один переводчик интерфейса» — это не он: здесь переводятся материалы, а не строки темы.

Важно: плагин не из каталога WordPress.org (в шапке файла Plugin URI указывает на example.com), ставится копированием каталога в wp-content/plugins/. Это ваш собственный код: обновлений «из коробки» не будет, обновлять нужно вручную.

TL;DR: включить за десять минут

  1. Скопируйте каталог multilingual-engine в wp-content/plugins/ и активируйте плагин.
  2. Откройте в админке отдельное меню Multi Lang (иконка перевода, admin.php?page=multilang-settings) — не «Настройки → Общие».
  3. Задайте ml_available_languages (например ru,en,de) и ml_default_language (например ru).
  4. Откройте главную: плагин добавит префикс языка и поставит куку ml_lang.
  5. Вставьте переключатель: шорткод [language_switcher] или виджет Language Switcher.

Проверка за минуту:

# список языков и язык по умолчанию
wp option get ml_available_languages
wp option get ml_default_language

# заголовки: должен быть 302 с /about/ на /ru/about/ (если перевод существует)
curl -I https://example.com/about/

Ожидаемо: 302 и Location: https://example.com/ru/about/. Если редиректа нет — проверьте, что запись действительно существует по целевому адресу (плагин намеренно не перенаправляет на несуществующие URL).

Оглавление

Что решает плагин и когда он уместен

Задача типовая: сайт на WordPress, материалы нужны на двух-трёх языках, а тяжеловесные коммерческие решения не хочется ставить по трём причинам — лицензия, вес, вмешательство в структуру URL и таблицы.

Multilingual Engine решает узкий круг задач:

Задача Решает Комментарий
Языковые версии записей и страниц в одной установке Да Отдельная запись на язык, общая группа переводов
Языковой префикс в URL (/en/post-slug/) Да Редирект 302 на префикс, если его нет
Быстрая выборка по языку Да Таксономия ml_language, а не метазапросы
hreflang в <head> Да Ссылки на альтернативные версии
REST-мета для внешних сценариев Да _ml_language, _ml_group_id
Перевод интерфейса темы и плагинов (строки) Нет Это задача load_plugin_textdomain и .po/.mo
Мультисайт (по сайту на язык) Нет Плагин рассчитан на одиночную установку

Выбор между этим плагином и Polylang/WPML — вопрос не «лучше/хуже», а «кто владеет структурой данных». Здесь структура простая и полностью ваша: две мета-записи, одна таксономия, никаких переопределений запросов в ядре.

Как это устроено внутри

Четыре сущности, вокруг которых крутится всё остальное:

Сущность Имя в коде Назначение
Таксономия языка ml_language Привязка записи к языку; непубличная (public = false, show_ui = false, show_in_rest = false), только для post и page
Мета «группа переводов» _ml_group_id Общий идентификатор всех версий одной статьи, например mlg_64b034b4b7e44.18830524
Мета «язык» _ml_language Код языка записи, доступен в REST (show_in_rest = true)
Кука ml_lang Последний выбранный язык: 1 год, path=/, SameSite=Lax, secure при HTTPS, HttpOnly — нет

Таксономия создаётся не «при активации», а на init — вместе с регистрацией метаполей. Схему базы плагин не меняет: таксономия и мета живут в стандартных таблицах WordPress.

Почему таксономия, а не мета: выборка по термину идёт по индексированной связи term_relationships, тогда как meta_query на больших объёмах превращается в JOIN по postmeta и заметно тормозит. Это ровно тот случай, когда «ещё одна таксономия» экономит секунды на листингах.

Установка и требования

Компонент Минимум Рекомендовано
WordPress 6.8 актуальная LTS
PHP 8.3 8.3.x
Веб-сервер Apache/Nginx текущий стабильный
База данных MySQL 5.7 / MariaDB 10.3 новые версии InnoDB

Установка:

cd /var/www/example.com/wp-content/plugins
unzip multilingual-engine.zip
chown -R www-data:www-data multilingual-engine
wp plugin activate multilingual-engine

Права важны: если каталог принадлежит root, автообновления и правки через админку работать не будут, а часть хостингов отдаст 500 при попытке записи в лог.

Настройки: меню Multi Lang

Плагин добавляет отдельный пункт меню в админке — Multi Lang с иконкой dashicons-translation. Настройки лежат на странице admin.php?page=multilang-settings, а не в «Настройки → Общие» (это частая ошибка при переносе инструкций со старых версий).

Опция Что задаёт Значение по умолчанию
ml_available_languages Список кодов ISO через запятую en
ml_default_language Язык-фолбэк и язык интерфейса для гостей первый из списка
ml_auto_create_drafts Создавать пустые черновики переводов при сохранении записи включено (1)
ml_redirect_missing Редирект на язык по умолчанию, если перевода нет выключено (0)
ml_copy_home Копировать главную страницу для языков выключено (0)
ml_user_passwords Пароли служебных учётных записей языков генерируются

Список языков нормализуется: обрезка пробелов, нижний регистр, удаление дублей — то есть RU, En , ru превратится в ru,en без ругани в интерфейсе.

Отдельный блок на странице — массовое назначение языка для старого контента и пользователи языков. Второе нужно редко, но объясняет странность, которую легко принять за баг: плагин создаёт учётные записи вида lang_ru, lang_en. Ник такой учётки участвует в определении языка (см. ниже), а пароли складываются в ml_user_passwords.

Определение языка: реальный порядок приоритетов

Порядок в коде currentLang() — важная деталь, потому что в старых описаниях он перепутан.

Приоритет Источник Условие Пример
1 Ник авторизованного пользователя Ник вида lang_xx lang_de → немецкая версия
2 Префикс URL Первый сегмент пути совпадает с кодом языка /de/about/
3 Кука ml_lang Выбрана ранее и входит в список ml_lang=fr
4 Язык по умолчанию Фолбэк ru

Два следствия, которые ломают ожидания:

  • Заголовок Accept-Language не учитывается. Автоматического «угадывания» по браузеру нет: гость с немецким браузером увидит язык по умолчанию, пока не переключит вручную. Для сайта с аудиторией из разных стран это надо помнить.
  • Кука сильнее отсутствия префикса. Если пользователь однажды выбрал de, а потом открыл «чистый» URL, плагин предпочтёт куку — и уведёт на /de/.

Кука ставится только при редиректе и живёт год. HttpOnly не выставлен, поэтому значение доступно скриптам на странице — если это важно, проще не хранить в ней ничего, кроме кода языка (так и есть).

URL, редиректы и канонические адреса

Логика maybeRedirect() намеренно осторожная:

  1. Админка, AJAX, ленты и предпросмотр не трогаются.
  2. Если первый сегмент пути уже равен текущему языку — редиректа нет.
  3. Иначе строится целевой адрес /{lang}/{путь} и проверяется его существование через url_to_postid().
  4. Если целевой адрес не существует и это не корень языка — редирект не выполняется. Так исключаются петли и «прыжки» на 404.

Корень языка (/en/ без пути) обрабатывается отдельно: для него есть обход канонического редиректа (bypassCanonicalForLangRoot), иначе WordPress уводил бы /en/ на главную без префикса.

Практический нюанс: редирект отдаётся кодом 302, а не 301. Для поисковых систем это значит «временный переход»; при стабильной структуре разумнее отдавать 301 на уровне nginx, оставив плагину только выбор контента:

# 301 для «чистых» URL языка по умолчанию — только если так решено на уровне проекта
location = /about/ {
    return 301 /ru/about/;
}

Рекомендация: не смешивайте два механизма. Либо префикс ставит только плагин, либо только nginx — иначе получите цепочку из двух редиректов и потеряете часть веса ссылок.

Контент: оригинал, переводы и черновики

Модель данных простая: одна статья = одна запись; перевод = отдельная запись с тем же _ml_group_id и другим _ml_language. Никаких «полей-переводов» внутри одной записи.

Автоматические черновики. Если ml_auto_create_drafts включено, при сохранении записи плагин создаёт пустые черновики для остальных языков и связывает их общей группой. Дальше редактор просто открывает нужный язык и пишет текст — структура уже готова.

Ручное создание. Выключите автогенерацию и создайте запись вручную, задав два метаполя:

wp post create --post_type=post --post_status=draft \
  --post_title="Hello world" \
  --meta_input='{"_ml_language":"de","_ml_group_id":"mlg_64b034b4b7e44.18830524"}'

Язык в списках записей. В таблице записей появляются ссылки вида Edit DE, Edit EN — они открывают соответствующую языковую версию. Фильтр ?ml_language=de в адресе списка оставляет только записи этого языка.

REST API: метаполя и внешние сценарии

Оба метаполя зарегистрированы через register_post_meta('', ...) с show_in_rest = true, то есть доступны в стандартном REST API для всех типов записей. Права — edit_posts.

# создать немецкую версию через REST (аутентификация — Application Password)
curl -X POST https://example.com/wp-json/wp/v2/posts \
  -H "Authorization: Basic $(printf 'user:app-password' | base64)" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Hallo Welt!",
    "content": "<p>Deutsche Version…</p>",
    "status": "publish",
    "meta": {
      "_ml_language": "de",
      "_ml_group_id": "mlg_64b034b4b7e44.18830524"
    }
  }'

Важно: группа переводов — произвольная строка. Плагин сравнивает её как есть, поэтому mlg_... и MLG_... — разные группы. Генерируйте значение один раз и храните.

Переключатель языка: шорткод, виджет, PHP

Три способа вывести переключатель:

[language_switcher]
// в шаблоне темы: ссылка на французскую версию текущего URL
$plugin = \MultilingualEngine\Plugin::instance();
echo esc_url( $plugin::translateUrl( get_permalink(), 'fr', $plugin->langs ) );

Третий — виджет Language Switcher в «Внешний вид → Виджеты». Все три используют один и тот же хелпер translateUrl(), который подменяет первый сегмент пути, сохраняя остальную часть адреса.

SEO: hreflang и что он не делает

Плагин вставляет в <head> ссылки <link rel="alternate" hreflang="…" href="…"> на доступные переводы. Настройки не требуются.

Чего он не делает:

  • не формирует sitemap.xml с языковыми версиями (это отдельная задача, обычно решается SEO-плагином);
  • не проставляет canonical на «правильную» языковую версию;
  • не мешает дублированию текста, если перевод сделан машинно и совпадает с оригиналом.

Для мультиязычного сайта минимальный комплект — hreflang + собственный canonical + разные <title> и <meta name="description"> на каждом языке. Первое даёт плагин, остальное — тема и SEO-плагин.

Обслуживание через WP-CLI

Поиск записей, у которых не хватает переводов:

wp eval '
$plugin = \MultilingualEngine\Plugin::instance();
$langs  = $plugin->langs;
foreach ( get_posts(["post_type"=>"post","posts_per_page"=>-1,"fields"=>"ids"]) as $id ) {
    $have = wp_get_post_terms($id, "ml_language", ["fields"=>"slugs"]);
    if ( count($have) < count($langs) ) {
        printf("%d\t%s\t%s\n", $id, get_post_meta($id, "_ml_group_id", true), implode(",", $have));
    }
}'

Массовое назначение языка старым записям делает кнопка в настройках, но то же можно сделать из консоли:

wp post term set 1234 ml_language ru --by=id

Важно: удаление черновиков — операция необратимая. Сначала список, потом бэкап, только потом удаление:

# 1) посмотреть, что попадёт под удаление
wp post list --post_status=draft --meta_key=_ml_group_id --fields=ID,post_title --format=table

# 2) бэкап
wp db export backup-before-cleanup.sql

# 3) удаление (только после проверки списка)
wp post list --post_status=draft --meta_key=_ml_group_id --format=ids \
  | xargs -r -n1 wp post delete --force

Подводные камни

1. Деактивация ломает URL. Префиксы перестают обрабатываться, и /en/post/ отдаёт 404. Перед отключением подготовьте редиректы на «чистые» адреса.

2. Удаление плагина не удаляет данные. Таксономия, мета и опции остаются в базе. Это осознанное решение: контент важнее чистоты. Чистить — вручную и после бэкапа.

3. 302 в редиректах. Для стабильной структуры поисковику нужен 301; если это принципиально, делайте редирект на уровне веб-сервера.

4. Нет Accept-Language. Гость из Германии по умолчанию увидит русскую версию. Для международного трафика стоит добавить баннер выбора языка или переопределить детектор своим фильтром.

5. Кука HttpOnly выключена. Код языка читается скриптами. Само значение безобидно, но не превращайте эту куку в хранилище пользовательских предпочтений.

6. Мультисайт не поддерживается. Для сети сайтов нужна другая архитектура: отдельный сайт на язык либо Polylang Pro/WPML.

7. Дублирование контента. Каждый перевод — отдельная запись со своим URL. Если переводы генерируются автоматически и совпадают с оригиналом, поисковики увидят дубли; hreflang помогает, но не заменяет нормальный перевод.

8. Расхождение списка языков и данных. Убрали язык из настроек — записи с этим кодом остались в таксономии. Их нужно переназначить, иначе они выпадут из листингов.

Показательные случаи

Случай 1: «редирект в никуда». После добавления языка de в список главная начала уводить на /de/, где не было ни одной записи, — и это выглядело как поломка сайта. Причина: ml_copy_home выключен, копия главной не создана, а сам редирект на корень языка плагин выполняет без проверки существования записей. Лечится кнопкой копирования главной или отключением лишнего языка.

Случай 2: «переводы есть, а листинг пуст». Редактор создавал переводы вручную и не заполнял _ml_language: записи существовали, но фильтр по языку их не видел. Лечится массовым назначением языка и привычкой проверять метаполе после импорта.

Чек-лист после внедрения

  • [ ] Список языков и язык по умолчанию сохранены, лишних кодов нет.
  • [ ] Главная и ключевые страницы открываются с префиксом и без него (302).
  • [ ] В <head> есть hreflang на все языки, включая x-default (если нужен — ставится темой).
  • [ ] Переключатель выведен в шапке или подвале и не ломает вёрстку на мобильном.
  • [ ] Кука ml_lang ставится и сбрасывается корректно.
  • [ ] В REST метаполя видны и записываются (_ml_language, _ml_group_id).
  • [ ] Для старых записей язык назначен массово, «безымянных» записей нет.
  • [ ] Бэкап базы снят до любых массовых операций.
  • [ ] Проверено поведение при деактивации: план редиректов готов.

FAQ

Нужен ли плагин, если языков всего два и переводов мало? Для двух языков и десятка страниц достаточно префиксов и hreflang. Плагин начинает окупаться, когда записей сотни и нужен предсказуемый листинг по языку.

Почему /en/post/ отдаёт 404, хотя перевод существует? Проверьте _ml_language у записи и наличие языка в ml_available_languages. Если язык убрали из списка, префикс перестаёт распознаваться.

Можно ли отдавать 301 вместо 302? Да, но на уровне nginx/Apache: плагин всегда отдаёт 302. Два механизма одновременно держать не стоит.

Как добавить x-default в hreflang? Штатно — никак: плагин выводит только реальные языковые версии. x-default добавляется темой через wp_head с приоритетом ниже плагина.

Что будет с сайтом при удалении плагина? Контент и метаданные останутся, но префиксы перестанут обрабатываться. Сначала редиректы, потом удаление.

Работает ли с WooCommerce? Таксономия ml_language регистрируется для post и page, поэтому товары нужно подключать отдельно — расширением списка типов записей в коде.

Итог

Multilingual Engine закрывает узкую, но частую задачу: языковые версии материалов в одной установке WordPress, без внешних зависимостей и без вмешательства в ядро. Сильные стороны — простая модель данных, быстрый листинг по языку и готовый hreflang; слабые — ручное управление переводами, 302 в редиректах и отсутствие мультисайта.

Если языков два-три и контент переводится вручную, этого достаточно. Если нужны автоматические переводы, отдельные домены под языки и синхронизация с внешними системами — смотрите в сторону зрелых плагинов, но готовьтесь к их структуре данных.

Цитата: Мультиязычность ломается не на переводе текста, а на структуре URL: как только появляется второй способ адресовать ту же страницу, кто-то обязательно придёт с вопросом «почему у меня 404».

Связанные материалы

LLM Summary

  • Тема: мультиязычный WordPress на языковых префиксах URL без WPML и Polylang.
  • Задача: дать сайту языковые версии статей, сохранив быстрые выборки и корректный hreflang.
  • Платформа: WordPress 6.8+, PHP 8.3+; плагин Multilingual Engine 1.09.5, одиночная установка (не Multisite).
  • Краткое решение: включить плагин, задать ml_available_languages и ml_default_language в меню Multi Lang, вывести [language_switcher]; язык записи хранится в таксономии ml_language, переводы связываются метаполем _ml_group_id.
  • Ключевые команды/действия: wp option get ml_available_languages; wp post term set <ID> ml_language <code> --by=id; wp eval для поиска неполных переводов; REST POST /wp-json/wp/v2/posts с meta._ml_language и meta._ml_group_id.
  • Основные риски: редирект 302 вместо 301; отсутствие учёта Accept-Language; 404 после деактивации плагина; дубли контента при машинном переводе; необратимое удаление черновиков.
  • Когда применять: 2–3 языка, ручной перевод, одна установка WordPress, нужен быстрый листинг по языку и hreflang.
  • Когда не применять: мультисайт, автоматический перевод больших объёмов, требование «сайт на язык в отдельном домене».
  • Проверка результата: curl -I /about/ возвращает 302 на /ru/about/; в <head> есть hreflang на все языки; метаполя видны в REST; записи без _ml_language отсутствуют.