Интеграция Extrafields в плагин I18n в Cotonti CMF
подробное руководство по внесенным изменениям, описывающее каждый файл, логику работы и примеры использования. Материал предназначен для разработчиков, желающих понять принципы интеграции Extrafields в собственные плагины Cotonti.
Полное руководство по интеграции Extrafields в плагин I18n для Cotonti CMF
Введение
Штатный плагин I18n (Content Internationalization) для Cotonti CMF предоставляет возможность создавать многоязычные версии страниц и категорий. Однако изначально он не поддерживал механизм Extrafields (дополнительных полей), который широко используется в других модулях Cotonti (например, в стандартном модуле Pages) для динамического добавления произвольных полей к сущностям без изменения кода.
Целью данной работы является полная интеграция Extrafields в мною модифицированный плагин I18n, а именно:
- Добавление поддержки дополнительных полей для переводов страниц (
cot_i18n_pages). - Реализация форм добавления и редактирования переводов с динамическими полями.
- Корректное сохранение и загрузка значений Extrafields.
- Вывод значений дополнительных полей в шаблонах:
- На странице просмотра статьи.
- В списках статей.
- В «шапке» сайта (например, для SEO-тегов).
- Предоставление гибких инструментов для ручного и автоматического вывода полей.
В результате мы получили полноценное расширение функциональности плагина I18n, позволяющее администраторам создавать любые дополнительные поля для переводов (текстовые, текстовые области, даты, файлы и т.д.) и использовать их наравне с обычными полями страниц.
Данный документ представляет собой подробное руководство по внесенным изменениям, описывающее каждый файл, логику работы и примеры использования. Материал предназначен для разработчиков, желающих понять принципы интеграции Extrafields в собственные плагины Cotonti.Тема обсуждения в этом разделе форума.
Часть 1. Архитектура и подготовка
1.1. Обзор механизма Extrafields в Cotonti
Extrafields (дополнительные поля) в Cotonti управляются через API, описанный в файле system/extrafields.php. Основные функции:
cot_extrafield_add()– регистрирует новое поле в таблицеcot_extra_fieldsи добавляет соответствующую колонку в целевую таблицу.cot_extrafield_remove()– удаляет поле.cot_build_extrafields()– генерирует HTML-элемент формы для ввода значения поля.cot_import_extrafields()– валидирует и возвращает значение поля из HTTP-запроса.cot_build_extrafields_data()– форматирует значение для вывода в шаблоне.
Для того чтобы таблица БД стала доступной для управления Extrafields через административную панель,
в
https://cotonti.local/ru/admin/extrafields?n=cot_i18n_pages
Управление сайтом ⇒ Прочее ⇒ Экстраполя ⇒ Таблица cot_i18n_pages - Переводы страницнеобходимо добавить её в «белый список» ($extra_whitelist) и “повесть” на хук admin.extrafields.first.
Именно так сделано, например, в модуле Pages (файл
modules/page/page.extrafields.php
1.2. Структура таблицы переводов
Исходная таблица cot_i18n_pages содержит следующие поля:
ipage_id– идентификатор оригинальной страницы.ipage_locale– код языка (например,en,ua).ipage_translatorid,ipage_translatorname– информация о переводчике.ipage_date,ipage_title,ipage_desc,ipage_text– переведённые данные.
Для поддержки Extrafields необходимо, чтобы администратор мог добавлять новые колонки в эту таблицу через админ-интерфейс. При этом API Extrafields автоматически создаст колонки с префиксом ipage_, как того требует логика плагина (аналогично page_ для таблицы cot_pages).
Часть 2. Регистрация таблицы в админ-панели Extrafields
2.1. Файл plugins/i18n/i18n.extrafields.php
Для того чтобы таблица cot_i18n_pages появилась в списке доступных для управления Extrafields, необходимо создать файл, подключаемый через хук admin.extrafields.first.
Путь: plugins/i18n/i18n.extrafields.php
Содержимое:
<?php
/* ====================
[BEGIN_COT_EXT]
Hooks=admin.extrafields.first
[END_COT_EXT]
==================== */
// файл новый i18n.extrafields.php
// Путь: plugins/i18n/i18n.extrafields.php
defined('COT_CODE') or die('Wrong URL.');
require_once cot_incfile('i18n', 'plug');
$extra_whitelist[$db_i18n_pages] = [
'name' => $db_i18n_pages,
'caption' => $L['i18n_pages'],
'type' => 'plug',
'code' => 'i18n',
'tags' => [
'i18n.page.tpl' => '{I18N_PAGE_FORM_XXXXX}, {I18N_PAGE_FORM_XXXXX_TITLE}',
]
];
Про себя всегдя читаю так:
создать файл, (обычно, но невсегда это: префикс расширения и суфикс с именем самого хука, на который будем вешать код логики этого файла).
В корне плагина создаем файл i18n.extrafields.php
в файл i18n.extrafields.php помещаем код логики:
defined('COT_CODE') or die('Wrong URL.');
require_once cot_incfile('i18n', 'plug');
$extra_whitelist[$db_i18n_pages] = [
.....
];затем обьявляем,
<?php
/* ====================
[BEGIN_COT_EXT]
Hooks=admin.extrafields.first
[END_COT_EXT]
==================== */что код этой логики “вешаем на крючок” - подключаем к хуку admin.extrafields.first
где этот крючок “прибит, завинчен, закреплен, расположен, что бы на него что-то вешали”, а именно в файле
/system/admin/admin.extrafields.php
а конкретно в участке
/* === Hook === */
foreach (cot_getextplugins('admin.extrafields.first') as $pl) {
include $pl;
}
/* ===== */
Как это работает в Cotonti (размещение хука и подключение кода к нему):
Файл, в котором непосредственно происходит вызов хука (висит крючок) с именем'admin.extrafields.first'
и, соответственно, подключение файла с нужным нам кодом в i18n.extrafields.php, — это
/system/admin/admin.extrafields.php
Файл /system/extrafields.php не содержит вызовов хуков, он является библиотекой функций API для работы с дополнительными полями и подключается в admin.extrafields.php через require_once. Сам он не инициирует загрузку плагинов.
Таким образом, последовательность:
Открывается admin.php?m=extrafields.
Загружается /system/admin/admin.extrafields.php.
Внутри него выполняется require_once cot_incfile('extrafields'); (подключается /system/extrafields.php).
Затем в admin.extrafields.php срабатывает хук admin.extrafields.first, который и подключает i18n.extrafields.php (и другие файлы расширений).
Конкретная последовательность выполнения файлов и работа хуков:
(это описывает принцип работы хуков в Cotonti, поэтому пишу здесь как шпаргалку, особенно для новичков)
Пользователь открывает админку доп. полей:
URL: admin.php?m=extrafields
Система Cotonti загружает файл:
/system/admin/admin.extrafields.php
В начале этого файла выполняется:
require_once cot_incfile('extrafields');
→ это подключает /system/extrafields.php (библиотеку функций API).
Затем в admin.extrafields.php инициализируется массив $extra_whitelist
(изначально в нём только $db_structure – категории).
Сразу после этого идёт хук:
foreach (cot_getextplugins('admin.extrafields.first') as $pl) {
include $pl;
}
Система ищет все файлы, зарегистрированные на хук admin.extrafields.first.
Один из них — новый файл i18n.extrafields.php (из папки плагина i18n который создали).
Файл i18n.extrafields.php подключается прямо в этом месте.
Внутри него выполняется код:
$extra_whitelist[$db_i18n_pages] = [ ... ];
Таким образом, в глобальный массив $extra_whitelist добавляется информация о таблице $db_i18n_pages (локализация контента страницы).
После завершения цикла хуков admin.extrafields.php продолжает работу.
Далее идёт построение списка таблиц БД для управления Экстраполями (дополнительными полями), и в этом списке уже присутствуют страницы.
Итог:
/system/extrafields.php загружается первым, но он лишь предоставляет функции и не влияет на хуки.
/system/admin/admin.extrafields.php — основной файл, который инициирует хук admin.extrafields.first.
i18n.extrafields.php срабатывает в момент выполнения хука, дополняя список разрешённых таблиц.
Таким образом, код из i18n.extrafields.php выполняется до отображения интерфейса админки, но после загрузки основных функций и начальной инициализации admin.extrafields.php.
Пояснения:
$db_i18n_pages– глобальная переменная, содержащая полное имя таблицы (например,cot_i18n_pages).caption– локализованное название, берётся из языкового файла.tags– подсказка для администратора, какие теги можно использовать в шаблонах (в данном случае для формы редактирования перевода).
Языковой файл (plugins/i18n/lang/i18n.ru.lang.php) дополнен строкой:
$L['i18n_pages']='Переводы страниц';После добавления этого файла таблица cot_i18n_pages становится доступной в разделе «Администрирование → Extrafields», и администратор может создавать для неё любые дополнительные поля.
я вот тупанул, и мне выдало:
Warning: Undefined array key "i18n_pages"
in /home/..../public_html/plugins/i18n/i18n.extrafields.php on line 16 никогда не бойтесь таких ошибок - критичного ничего нет,
мы добавили строку локизации $L['i18n_pages'] в файл i18n.extrafields но сам ключ забыли прописать в языковых файлах.
Отрываем папку с файлами строк локализации
/plugins/i18n/lang/
и в файл нужного языка, например i18n.ru.lang.php
добавляем строку
$L['i18n_pages'] = 'Переводы страниц';и так для каждого языка локализации определяем ключ для элемента 'caption' => $L['i18n_pages'], в массиве $extra_whitelist[$db_i18n_pages] = []; нашего файла i18n.extrafields
Часть 3. Модификация основного скрипта переводов
3.1. Файл plugins/i18n/inc/i18n.page.php
Этот файл отвечает за добавление (a=add), редактирование (a=edit) и удаление (a=delete) переводов страниц. Внесённые изменения обеспечивают загрузку, отображение, валидацию и сохранение Extrafields.
3.1.1. Подключение API Extrafields
В начале файла после стандартных require_once
а именно, после строки:
require_once cot_incfile('forms');добавляем
// === EXTRAFIELDS: Подключаем API дополнительных полей ===
require_once cot_incfile('extrafields');3.1.2. Загрузка конфигурации полей
Перед строкой
if ($a == 'add' && empty($pag_i18n)) {добавляем
// === EXTRAFIELDS: Загружаем конфигурацию дополнительных полей для таблицы переводов ===
$extrafields = Cot::$extrafields[$db_i18n_pages] ?? [];После определения $pag_i18n (загрузки существующего перевода).Этот массив содержит все зарегистрированные дополнительные поля для таблицы переводов.
3.1.3. Режим добавления ($a == 'add')
Дальше, сразу после массива (примерно строки 65-78):
$pag_i18n = [
'ipage_id' => $id,
'ipage_locale' => $selected_locale,
'ipage_translatorid' => Cot::$usr['id'],
'ipage_translatorname' => Cot::$usr['name'],
'ipage_date' => Cot::$sys['now'],
'ipage_title' => cot_import('title', 'P', 'TXT'),
'ipage_desc' => cot_import('desc', 'P', 'TXT'),
'ipage_text' => cot_import('translate_text', 'P', 'HTM')
];добавляем импорт, вставляем код
// === EXTRAFIELDS: Импортируем значения дополнительных полей из POST ===
foreach ($extrafields as $exfld) {
$fieldName = 'ipage_' . $exfld['field_name'];
$pag_i18n[$fieldName] = cot_import_extrafields(
'ri18n' . $exfld['field_name'],
$exfld,
'P',
'',
'i18n_'
);
}Импорт и сохранение
При POST-запросе после сбора основных полей происходит импорт Extrafields:
Функция cot_import_extrafields() корректно обрабатывает все типы полей, включая файлы, чекбоксы и даты.
Отображение полей в форме
В шаблон передаются теги для каждого поля:
после массива:
$t->assign([
'I18N_ACTION' => cot_url('plug', "e=i18n&m=page&a=add&id=$id"),
'I18N_TITLE' => Cot::$L['i18n_adding'],
'I18N_ORIGINAL_LANG' => $i18n_locales[Cot::$cfg['defaultlang']],
'I18N_LOCALIZED_LANG' => cot_selectbox($selected_in_selector, 'locale', $lc_values, $lc_names, false),
'I18N_PAGE_TITLE' => htmlspecialchars($pag['page_title']),
'I18N_PAGE_DESC' => htmlspecialchars($pag['page_desc']),
'I18N_PAGE_TEXT' => cot_parse($pag['page_text'], Cot::$cfg['page']['markup']),
'I18N_IPAGE_TITLE' => htmlspecialchars($title_val),
'I18N_IPAGE_DESC' => htmlspecialchars($desc_val),
'I18N_IPAGE_TEXT' => cot_textarea('translate_text', $text_val, 32, 80, '', 'input_textarea_editor')
]);вставляем код
// === EXTRAFIELDS: Генерируем и передаём в шаблон поля для ввода дополнительных данных ===
if (!empty($extrafields)) {
foreach ($extrafields as $exfld) {
$uname = strtoupper($exfld['field_name']);
$fieldValue = $pag_i18n['ipage_' . $exfld['field_name']] ?? null;
$extrafieldElement = cot_build_extrafields(
'ri18n' . $exfld['field_name'],
$exfld,
$fieldValue
);
$extrafieldTitle = cot_extrafield_title($exfld, 'i18n_');
// === FIX: Добавлены общие теги EXTRAFLD для шаблона ===
$t->assign([
'I18N_PAGE_FORM_' . $uname => $extrafieldElement,
'I18N_PAGE_FORM_' . $uname . '_TITLE' => $extrafieldTitle,
'I18N_PAGE_FORM_EXTRAFLD' => $extrafieldElement,
'I18N_PAGE_FORM_EXTRAFLD_TITLE' => $extrafieldTitle,
]);
$t->parse('MAIN.EXTRAFLD');
}
}
Здесь используются два типа тегов:
- Специфичные для каждого поля (
I18N_PAGE_FORM_ИМЯПОЛЯ). - Общие
I18N_PAGE_FORM_EXTRAFLDи_TITLE, которые позволяют вывести все поля в цикле через один блок<!-- BEGIN: EXTRAFLD -->.
3.1.4. Режим редактирования ($a == 'edit')
Находим строку:
if ($_SERVER['REQUEST_METHOD'] == 'POST') {Перед ней, добавить код:
// === EXTRAFIELDS: Сохраняем старые данные для корректной обработки файловых полей ===
$pag_i18n_old = $pag_i18n;Сохранение старых данных
Для корректной работы файловых полей (чтобы при удалении файла он физически удалялся с сервера) необходимо сохранить старые значения:
$pag_i18n_old=$pag_i18n;
Импорт при POST
При сохранении изменений Extrafields импортируются с передачей старых значений:
находим строки (примерно 200):
$pag_i18n['ipage_locale'] = $new_locale;
if (cot_error_found()) {
и именно между ними вставляем
// === EXTRAFIELDS: Импортируем значения дополнительных полей из POST, передавая старые значения ===
foreach ($extrafields as $exfld) {
$fieldName = 'ipage_' . $exfld['field_name'];
$oldValue = $pag_i18n_old[$fieldName] ?? '';
$pag_i18n[$fieldName] = cot_import_extrafields(
'ri18n' . $exfld['field_name'],
$exfld,
'P',
$oldValue,
'i18n_'
);
}Критическое исправление сохранения
В моем модифицированном плагине обновление записи выполнялось с явным перечислением полей:
Cot::$db->update(Cot::$db->i18n_pages,
[
'ipage_locale' => $new_locale,
'ipage_date' => $pag_i18n['ipage_date'],
'ipage_title' => $pag_i18n['ipage_title'],
'ipage_desc' => $pag_i18n['ipage_desc'],
'ipage_text' => $pag_i18n['ipage_text']
],
"ipage_id = ? AND ipage_locale = ?",
[$id, $i18n_locale]
);Такой подход игнорирует любые дополнительные поля. Чтобы они сохранялись, необходимо передать весь массив $pag_i18n, предварительно удалив из него первичный ключ ipage_id:
поэтому этот блок кода заменить на код ниже:
unset($pag_i18n['ipage_id']);
// старый ДиБи-update заменили на новый
Cot::$db->update(Cot::$db->i18n_pages, $pag_i18n, "ipage_id = ? AND ipage_locale = ?", [$id, $i18n_locale]);Это позволяет API базы данных автоматически обновить все колонки, включая динамически добавленные.
Отображение полей в форме редактирования
Аналогично режиму добавления, поля генерируются с учётом уже введённых значений (в том числе после ошибок валидации). Значение берётся либо из $pag_i18n, либо из POST.
находим массив для вывода тегов в шаблон:
$t->assign([
'I18N_ACTION' => cot_url('plug', "e=i18n&m=page&a=edit&id=$id&l=$i18n_locale"),
'I18N_TITLE' => Cot::$L['i18n_editing'],
'I18N_ORIGINAL_LANG' => $i18n_locales[Cot::$cfg['defaultlang']],
'I18N_LOCALIZED_LANG' => cot_selectbox($selected_in_selector, 'locale', $lc_values, $lc_names, false),
'I18N_PAGE_TITLE' => htmlspecialchars($pag['page_title']),
'I18N_PAGE_DESC' => htmlspecialchars($pag['page_desc']),
'I18N_PAGE_TEXT' => cot_parse($pag['page_text'], Cot::$cfg['page']['markup']),
'I18N_IPAGE_TITLE' => htmlspecialchars($title_val),
'I18N_IPAGE_DESC' => htmlspecialchars($desc_val),
'I18N_IPAGE_TEXT' => cot_textarea('translate_text', $text_val, 32, 80, '', 'input_textarea_editor')
]);и сразу после него добавляем вывод в шаблон наших экстраполей:
// === EXTRAFIELDS: Генерируем и передаём в шаблон поля для ввода дополнительных данных (редактирование) ===
if (!empty($extrafields)) {
foreach ($extrafields as $exfld) {
$uname = strtoupper($exfld['field_name']);
$fieldValue = $pag_i18n['ipage_' . $exfld['field_name']] ?? null;
$extrafieldElement = cot_build_extrafields(
'ri18n' . $exfld['field_name'],
$exfld,
$fieldValue
);
$extrafieldTitle = cot_extrafield_title($exfld, 'i18n_');
// === FIX: Добавлены общие теги EXTRAFLD для шаблона ===
$t->assign([
'I18N_PAGE_FORM_' . $uname => $extrafieldElement,
'I18N_PAGE_FORM_' . $uname . '_TITLE' => $extrafieldTitle,
'I18N_PAGE_FORM_EXTRAFLD' => $extrafieldElement,
'I18N_PAGE_FORM_EXTRAFLD_TITLE' => $extrafieldTitle,
]);
$t->parse('MAIN.EXTRAFLD');
}
}
Всё!
на этом правка файла /plugins/i18n/inc/i18n.page.php закончена.
Сам файл полностью на github тут.
Теперь открваем сам шаблон заполнения и редактирования переводов контента на другие языки
стандартный шабло лежит в
/plugins/i18n/tpl/i18n.page.tpl
и докидуем динамический вывод полей
<!-- ===== БЛОК ДОПОЛНИТЕЛЬНЫХ ПОЛЕЙ (EXTRAFIELDS) ===== -->
<!-- Каждое поле выводится в цикле через блок EXTRAFLD -->
<!-- BEGIN: EXTRAFLD -->
<div class="py-2">
<label class="form-label fw-semibold">{I18N_PAGE_FORM_EXTRAFLD_TITLE}</label>
{I18N_PAGE_FORM_EXTRAFLD}
</div>
<!-- END: EXTRAFLD -->
<!-- ===== КОНЕЦ БЛОКА EXTRAFIELDS ===== -->
Часть 4. Вывод Extrafields в шаблонах модуля страниц
Открываем файл /plugins/i18n/i18n.pagetags.php
Чтобы не рассусоливать, находим код:
if (!empty($page_data['ipage_title'])) {
$text = cot_parse($page_data['ipage_text'], Cot::$cfg['page']['markup'], $page_data['page_parser']);
$text_cut = ((int) $textLength > 0) ? cot_string_truncate($text, $textLength) : cot_cut_more($text);
$cutted = mb_strlen($text) > mb_strlen($text_cut);
$pageDescription = !empty($page_data['ipage_desc'])
? htmlspecialchars($page_data['ipage_desc'])
: '';
$page_link = array(array(cot_url('page', $urlparams), $page_data['ipage_title']));
$i18n_array = array_merge(
$i18n_array,
[
'URL' => cot_url('page', $urlparams),
'TITLE' => htmlspecialchars($page_data['ipage_title']),
'BREADCRUMBS' => cot_breadcrumbs(array_merge($pagepath, $page_link), $pagepath_home),
'DESCRIPTION' => $pageDescription,
'TEXT' => $text,
'TEXT_CUT' => $text_cut,
'TEXT_IS_CUT' => $cutted,
'DESCRIPTION_OR_TEXT' => $pageDescription !== '' ? $pageDescription : $text,
'DESCRIPTION_OR_TEXT_CUT' => $pageDescription !== '' ? $pageDescription : $text_cut,
'MORE' => $cutted ? cot_rc_link($page_data['page_pageurl'], Cot::$L['ReadMore']) : '',
'UPDATED_STAMP' => $page_data['ipage_date'],
]
);
if (isset(Cot::$cfg['legacyMode']) && Cot::$cfg['legacyMode']) {
$i18n_array = array_merge(
$i18n_array,
[
// @deprecated in 0.9.24
'SHORTTITLE' => htmlspecialchars($page_data['ipage_title']),
'DESC' => $pageDescription,
'DESC_OR_TEXT' => $pageDescription !== '' ? $pageDescription : $text,
]
);
}
}меняем его на код ниже
if (!empty($page_data['ipage_title'])) {
$text = cot_parse($page_data['ipage_text'], Cot::$cfg['page']['markup'], $page_data['page_parser']);
$text_cut = ((int) $textLength > 0) ? cot_string_truncate($text, $textLength) : cot_cut_more($text);
$cutted = mb_strlen($text) > mb_strlen($text_cut);
$pageDescription = !empty($page_data['ipage_desc'])
? htmlspecialchars($page_data['ipage_desc'])
: '';
$page_link = array(array(cot_url('page', $urlparams), $page_data['ipage_title']));
$i18n_array = array_merge(
$i18n_array,
[
'URL' => cot_url('page', $urlparams),
'TITLE' => htmlspecialchars($page_data['ipage_title']),
'BREADCRUMBS' => cot_breadcrumbs(array_merge($pagepath, $page_link), $pagepath_home),
'DESCRIPTION' => $pageDescription,
'TEXT' => $text,
'TEXT_CUT' => $text_cut,
'TEXT_IS_CUT' => $cutted,
'DESCRIPTION_OR_TEXT' => $pageDescription !== '' ? $pageDescription : $text,
'DESCRIPTION_OR_TEXT_CUT' => $pageDescription !== '' ? $pageDescription : $text_cut,
'MORE' => $cutted ? cot_rc_link($page_data['page_pageurl'], Cot::$L['ReadMore']) : '',
'UPDATED_STAMP' => $page_data['ipage_date'],
]
);
// === EXTRAFIELDS: Формируем теги для каждого дополнительного поля перевода ===
if (!empty(Cot::$extrafields[Cot::$db->i18n_pages])) {
foreach (Cot::$extrafields[Cot::$db->i18n_pages] as $exfld) {
$tag = 'I18N_PAGE_' . strtoupper($exfld['field_name']);
$value = $page_data['ipage_' . $exfld['field_name']] ?? null;
$i18n_array[$tag] = cot_build_extrafields_data('i18n', $exfld, $value, $page_data['page_parser']);
$i18n_array[$tag . '_TITLE'] = cot_extrafield_title($exfld, 'i18n_');
$i18n_array[$tag . '_VALUE'] = $value;
}
}
// === END EXTRAFIELDS ===
} else {
// === Сброс тегов экстраполей для страниц без перевода ===
if (!empty(Cot::$extrafields[Cot::$db->i18n_pages])) {
foreach (Cot::$extrafields[Cot::$db->i18n_pages] as $exfld) {
$tag = 'I18N_PAGE_' . strtoupper($exfld['field_name']);
$i18n_array[$tag] = '';
$i18n_array[$tag . '_TITLE'] = '';
$i18n_array[$tag . '_VALUE'] = '';
}
}
// === END СБРОС ===
}
Обратите внимание, что я никогда не использую
@deprecated in 0.9.24- устаревшее в мусорку
Теперь, кому интересно, - вчитываемся, что здесь происходит.
После сохранения данных необходимо отобразить их посетителям сайта. Cotonti предоставляет несколько уровней шаблонизации, и мы реализовали поддержку Extrafields для всех основных сценариев.
4.1. Индивидуальные теги через cot_generate_pagetags() (ручной вывод)
Функция cot_generate_pagetags() вызывается для каждой страницы (в списке или при одиночном просмотре) и возвращает массив тегов с префиксом. Плагин I18n переопределяет её через хук pagetags.main.
Файл: plugins/i18n/inc/i18n.pagetags.php
Внутри условия, когда перевод существует (!empty($page_data['ipage_title'])), добавляется цикл по Extrafields:
if (!empty(Cot::$extrafields[Cot::$db->i18n_pages])) {
foreach (Cot::$extrafields[Cot::$db->i18n_pages] as $exfld) {
$tag = 'I18N_PAGE_' . strtoupper($exfld['field_name']);
$value = $page_data['ipage_' . $exfld['field_name']] ?? null;
$i18n_array[$tag] = cot_build_extrafields_data('i18n', $exfld, $value, $page_data['page_parser']);
$i18n_array[$tag . '_TITLE'] = cot_extrafield_title($exfld, 'i18n_');
$i18n_array[$tag . '_VALUE'] = $value;
}
}Эти теги затем сливаются с основным массивом $temp_array и становятся доступными в шаблонах с соответствующим префиксом.
Пример использования в шаблоне page.tpl:
имя полей тупо для примера, использовалось для тестирования с успешным финалом
<!-- IF {I18N_META_TITLE} -->
<div class="news-item">
<span class="label">{I18N_META_TITLE_TITLE}:</span>
<span class="value">{I18N_META_TITLE}</span>
</div>
<!-- ENDIF -->
<!-- IF {I18N_META_DESCRIPTION} -->
<div class="news-item">
<span class="label">{I18N_META_DESCRIPTION_TITLE}:</span>
<span class="value">{I18N_META_DESCRIPTION}</span>
</div>
<!-- ENDIF -->
где META_DESCRIPTION - это имя экстраполя в нижнем регистре 'meta_description' при создания экстраполей для плагина i18n
где META_TITLE - это имя экстраполя в нижнем регистре 'meta_title' при создания экстрfполей для плагина i18nсмотрите скрин
Проблема дублирования в списках и её решение (немного забегая вперед, но чтоб не возращаться снова сюда)
При использовании в цикле списка статей (page.list.tpl) XTemplate сохраняет значения тегов между итерациями, если они не переопределены. Поэтому для статей без перевода теги Extrafields содержали данные от предыдущей статьи.
Решение: добавлен блок else после проверки if (!empty($page_data['ipage_title'])), который сбрасывает теги в пустые строки:
if (!empty($page_data['ipage_title'])) {
...
// === END EXTRAFIELDS ===
} else {
// === Сброс тегов экстраполей для страниц без перевода ===
if (!empty(Cot::$extrafields[Cot::$db->i18n_pages])) {
foreach (Cot::$extrafields[Cot::$db->i18n_pages] as $exfld) {
$tag = 'I18N_PAGE_' . strtoupper($exfld['field_name']);
$i18n_array[$tag] = '';
$i18n_array[$tag . '_TITLE'] = '';
$i18n_array[$tag . '_VALUE'] = '';
}
}
// === END СБРОС ===
}Теперь для каждой статьи без перевода поля гарантированно пусты.
4.2. Динамический вывод всех полей на странице просмотра
Для вывода всех полей сразу без ручного перечисления используется хук page.tags и блок <!-- BEGIN: EXTRAFLD -->.
Файл: plugins/i18n/inc/i18n.page.tags.php
В этом файле, который выполняется уже после создания объекта шаблона $t,
находим код
if ($i18n_admin) {
// Control tags
if (!empty($pag_i18n)) {
// Delete translation button and URL
$i18nDeleteUrl = cot_url(
'plug',
['e' => 'i18n', 'm' => 'page', 'a' => 'delete', 'id' => $id, 'l' => $i18n_locale]);
$i18nDeleteConfirmUrl = cot_confirm_url($i18nDeleteUrl, 'i18n', 'i18n_confirm_delete');
$t->assign([
'PAGE_I18N_DELETE' => cot_rc_link($i18nDeleteConfirmUrl, Cot::$L['i18n_delete'], 'class="confirmLink"'),
'PAGE_I18N_DELETE_URL' => $i18nDeleteConfirmUrl,
]);
}
}и сразу после него вставляем следующий код:
// === Extrafields for i18n pages — вывод в шаблон ===
if (!empty($pag_i18n) && !empty(Cot::$extrafields[Cot::$db->i18n_pages])) {
foreach (Cot::$extrafields[Cot::$db->i18n_pages] as $exfld) {
$tag = mb_strtoupper($exfld['field_name']);
$exfld_title = cot_extrafield_title($exfld, 'i18n_');
$temp_value = null;
if (isset($pag_i18n['ipage_' . $exfld['field_name']])) {
$temp_value = $pag_i18n['ipage_' . $exfld['field_name']];
}
// Индивидуальные теги (ручной вывод)
$t->assign([
'I18N_' . $tag . '_TITLE' => $exfld_title,
'I18N_' . $tag => cot_build_extrafields_data('i18n', $exfld, $temp_value, $pag['page_parser']),
'I18N_' . $tag . '_VALUE' => $temp_value,
]);
// Динамический вывод через блок EXTRAFLD
$t->assign([
'I18N_EXTRAFIELD_TITLE' => $exfld_title,
'I18N_EXTRAFIELD_VALUE' => cot_build_extrafields_data('i18n', $exfld, $temp_value, $pag['page_parser']),
]);
$t->parse('MAIN.EXTRAFLD');
}
}
Шаблон page.tpl должен содержать:
для динамического вывода
<!-- BEGIN: EXTRAFLD -->
<div class="form-group">
<label>{I18N_EXTRAFIELD_TITLE}</label>
<div>{I18N_EXTRAFIELD_VALUE}</div>
</div>
<!-- END: EXTRAFLD -->или индивидуально, как это было в примере выше с полями
{I18N_META_TITLE} и {I18N_META_DESCRIPTION}При парсинге для каждого поля будет выведен отдельный блок.
4.3. Передача Extrafields в шаблон «шапки» сайта (header.tpl)
Теперь переходим к самому интересному, из-за чего собственно и начался “сыр-бор” и движуха с экстраполями в плагине локализации контента, - это именно выдать отличимые от стандартных полей, именно свои заголовки и описание в мета-теги страницы.
Часто SEO-поля (мета-заголовок, описание) должны быть выведены в тегах <title> и <meta name="description">, которые находятся в header.tpl. Для этого мы расширили хук header.tags.
Файл: plugins/i18n/inc/i18n.header.tags.php
открываем его, и в самый конец добавляем
// ========== 4. ПЕРЕДАЧА ЭКСТРАПОЛЕЙ ПЕРЕВОДА В HEADER ==========
if ($env['ext'] == 'page' && isset($id) && $id > 0) {
// Загружаем перевод для текущего языка, если ещё не загружен
if (empty($pag_i18n) && !empty($i18n_locale)) {
$pag_i18n = cot_i18n_get_page($id, $i18n_locale);
}
if (!empty($pag_i18n) && !empty(Cot::$extrafields[Cot::$db->i18n_pages])) {
// Загружаем данные оригинальной страницы для парсера
$page_data = Cot::$db->query("SELECT page_parser FROM " . Cot::$db->pages . " WHERE page_id = ?", array($id))->fetch();
$parser = $page_data['page_parser'] ?? Cot::$cfg['page']['parser'];
foreach (Cot::$extrafields[Cot::$db->i18n_pages] as $exfld) {
$field_name = $exfld['field_name'];
$tag = 'I18N_HEADER_' . strtoupper($field_name);
$value = $pag_i18n['ipage_' . $field_name] ?? '';
// Присваиваем теги для header.tpl
$t->assign([
// без экранирования
// $tag => cot_build_extrafields_data('i18n', $exfld, $value, $parser),
// экранируем кавычки и другие специальные символы
$tag => htmlspecialchars(cot_build_extrafields_data('i18n', $exfld, $value, $parser), ENT_QUOTES, 'UTF-8'),
$tag . '_TITLE' => cot_extrafield_title($exfld, 'i18n_'),
$tag . '_VALUE' => $value,
]);
}
} else {
// Сброс тегов, если перевод отсутствует
if (!empty(Cot::$extrafields[Cot::$db->i18n_pages])) {
foreach (Cot::$extrafields[Cot::$db->i18n_pages] as $exfld) {
$tag = 'I18N_HEADER_' . strtoupper($exfld['field_name']);
$t->assign([
$tag => '',
$tag . '_TITLE' => '',
$tag . '_VALUE' => '',
]);
}
}
}
}мой полный файл /plugins/i18n/i18n.header.tags.php лежит в репозитории
обратите внимание, здесь пришлось применить экранирование, и вместо спецсимволов будут выводится их коды, например вместо " будет " - это потому, что в шаблоне мы вставляем {I18N_HEADER_META_DESCRIPTION} непосредственно в атрибут content="". Если значение содержит кавычки ( " ), это может сломать HTML. Поэтому лучше экранировать спецсимволы на стороне PHP перед передачей в шаблон, что мы и сделали, заменив строку
$tag => cot_build_extrafields_data('i18n', $exfld, $value, $parser),на
$tag => htmlspecialchars(cot_build_extrafields_data('i18n', $exfld, $value, $parser), ENT_QUOTES, 'UTF-8'),
Использование в header.tpl:
добавляем смело
<!-- IF {I18N_HEADER_META_TITLE} -->
<title>{I18N_HEADER_META_TITLE}</title>
<!-- ELSE -->
<title>{HEADER_TITLE}</title>
<!-- ENDIF -->
<!-- IF {I18N_HEADER_META_DESCRIPTION} -->
<meta name="description" content="{I18N_HEADER_META_DESCRIPTION}" />
<!-- ELSE -->
<!-- IF {HEADER_META_DESCRIPTION} -->
<meta name="description" content="{HEADER_META_DESCRIPTION}" />
<!-- ENDIF -->
<!-- ENDIF -->Таким образом, при наличии перевода с заполненными Extrafields meta_title и meta_description они заменяют стандартные мета-теги страницы.
Часть 5. Структура базы данных и примеры
5.1. Таблица cot_extra_fields
В ней хранятся определения всех дополнительных полей. Для нашей задачи были добавлены две записи для cot_i18n_pages:
sql
INSERTINTO`cot_extra_fields`(`field_location`,`field_name`,`field_type`,`field_html`,`field_variants`,`field_params`,`field_default`,`field_required`,`field_enabled`,`field_parse`,`field_description`)VALUES
('cot_i18n_pages','meta_title','input','<input class=\"form-control\" type=\"text\" name=\"{$name}\" value=\"{$value}\" maxlength=\"255\" />','','','',0,1,'HTML','Мета-заголовк'),
('cot_i18n_pages','meta_description','textarea','<textarea class=\"form-control\" name=\"{$name}\" rows=\"{$rows}\" cols=\"{$cols}\" maxlength=\"255\">{$value}</textarea>','','','',0,1,'HTML','Мета описание');5.2. Таблица cot_i18n_pages
При создании полей через админ-панель API Extrafields автоматически добавил колонки ipage_meta_title и ipage_meta_description:
sql
ALTERTABLE`cot_i18n_pages`ADD`ipage_meta_title`VARCHAR(255)DEFAULT'';
ALTERTABLE`cot_i18n_pages`ADD`ipage_meta_description`TEXT;Теперь таблица имеет следующую структуру (фрагмент):
sql
CREATETABLE`cot_i18n_pages`(
`ipage_id`intUNSIGNEDNOTNULL,
`ipage_locale`varchar(8)NOTNULLDEFAULT'en',
...
`ipage_meta_title`varchar(255)DEFAULT'',
`ipage_meta_description`text,
PRIMARYKEY(`ipage_id`,`ipage_locale`),
...
);5.3. Пример данных перевода с заполненными Extrafields
INSERTINTO`cot_i18n_pages`
(`ipage_id`,`ipage_locale`,`ipage_translatorid`,`ipage_translatorname`,`ipage_date`,`ipage_title`,`ipage_desc`,`ipage_text`,`ipage_meta_title`,`ipage_meta_description`)VALUES
(63,'en',3,'webitproff',1776872330,'Calculate Stupid Efficiency...','learn how to calculate...','<h1>...</h1>','Formulas, Examples','Conclusion...');Часть 6. Шаблоны и их модификация
Пройдемся снова по шаблонам, закрепим, что бы уже было в одном месте по финалу.
6.1. Форма перевода i18n.page.tpl
В форму были добавлены блоки для вывода Extrafields:
динамический вывод:
<!-- BEGIN: EXTRAFLD -->
<div class="py-2">
<label class="form-label fw-semibold">{I18N_PAGE_FORM_EXTRAFLD_TITLE}</label>
{I18N_PAGE_FORM_EXTRAFLD}
</div>
<!-- END: EXTRAFLD -->
6.2. Страница просмотра page.tpl
Используются два подхода:
Ручной вывод конкретных полей:
<!-- IF {I18N_META_TITLE} -->
<div class="news-item">
<span class="label">{I18N_META_TITLE_TITLE}:</span>
<span class="value">{I18N_META_TITLE}</span>
</div>
<!-- ENDIF -->
Динамический вывод всех полей:
<!-- BEGIN: EXTRAFLD -->
<div class="form-group">
<label>{I18N_EXTRAFIELD_TITLE}</label>
<div>{I18N_EXTRAFIELD_VALUE}</div>
</div>
<!-- END: EXTRAFLD -->6.3. Список статей page.list.tpl
Для вывода в списке доступны теги с префиксом LIST_ROW_I18N_PAGE_....
!!! Отвечает файл i18n.pagetags.php
Работает только индивидуальный (ручное прописывание) полей в шаблоне
Пример:
<!-- IF {LIST_ROW_I18N_PAGE_META_TITLE} -->
<div class="news-item">
<span class="label">{LIST_ROW_I18N_PAGE_META_TITLE_TITLE}:</span>
<span class="value">{LIST_ROW_I18N_PAGE_META_TITLE}</span>
</div>
<!-- ENDIF -->
<!-- IF {LIST_ROW_I18N_PAGE_META_DESCRIPTION} -->
<div class="news-item">
<span class="label">{LIST_ROW_I18N_PAGE_META_DESCRIPTION_TITLE}:</span>
<span class="value">{LIST_ROW_I18N_PAGE_META_DESCRIPTION}</span>
</div>
<!-- ENDIF -->6.4. Шапка сайта header.tpl
короткий вариант был выше, а здесь для полноты картиын:
<!--
/********************************************************************************
* File: header.tpl
* Extension: Core'
* Description: HTML template for header.tpl.
* Compatibility: CMF/CMS Cotonti Siena v0.9.26[](https://github.com/Cotonti/Cotonti)
* Dependencies:
* Bootstrap 5.3.+[](https://getbootstrap.com/);
* Font Awesome Free 7.1[](https://fontawesome.com/)
* Theme: Index36
* Version: 1.0.2
* Created: 01 Feb 2026
* Updated: 22 Apr 2026
* Copyright (c) 2026 webitproff | https://github.com/webitproff
* Source: https://github.com/webitproff/index36-cotonti-theme
* Demo : https://freelance-script.abuyfile.com/
* Help and support: https://abuyfile.com/ru/forums/cotonti/original/skins/index36
* License: BSD (Free distribution with saving Copyright (c) 2026 webitproff)
********************************************************************************/
-->
<!-- BEGIN: HEADER -->
<!DOCTYPE html>
<!-- IF {HTML_LANG} -->
<html lang="{HTML_LANG}" data-bs-theme="light">
<!-- ELSE -->
<html lang="{PHP.usr.lang}" data-bs-theme="light">
<!-- ENDIF -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- IF {I18N_HEADER_META_TITLE} -->
<title>{I18N_HEADER_META_TITLE}</title>
<!-- ELSE -->
<title>{HEADER_TITLE}</title>
<!-- ENDIF -->
<!-- IF {I18N_HEADER_META_DESCRIPTION} -->
<meta name="description" content="{I18N_HEADER_META_DESCRIPTION}" />
<!-- ELSE -->
<!-- IF {HEADER_META_DESCRIPTION} -->
<meta name="description" content="{HEADER_META_DESCRIPTION}" />
<!-- ENDIF -->
<!-- ENDIF -->
<!-- IF {HEADER_BASEHREF} -->
{HEADER_BASEHREF}
<!-- ENDIF -->
<!-- IF {HEADER_CANONICAL_URL} -->
<link rel="canonical" href="{HEADER_CANONICAL_URL}" />
<!-- ENDIF -->
<!-- IF {ALTERNATE_TAGS} -->
{ALTERNATE_TAGS}
<!-- ENDIF -->
<link rel="shortcut icon" href="favicon.ico" />
<link rel="icon" href="{PHP.cfg.themes_dir}/{PHP.theme}/img/icon.webp" type="image/svg+xml">
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<!-- IF {PHP.out.meta} -->
{PHP.out.meta}
<!-- ENDIF -->
<script>
(function () {
const storedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const defaultTheme = storedTheme || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-bs-theme', defaultTheme);
})();
</script>
{HEADER_HEAD}
</head>Часть 7. Итоги и проверка работоспособности
После внесения всех описанных изменений плагин I18n получил полную поддержку Extrafields, сравнимую с модулем Pages.
Проверка:
- Админ-панель: В разделе «Extrafields» появилась таблица
cot_i18n_pages. Администратор может создавать и редактировать поля любых типов. - Форма перевода: При добавлении/редактировании перевода отображаются все активные дополнительные поля. Введённые значения корректно сохраняются.
- Страница перевода: При просмотре страницы на языке, отличном от основного, поля выводятся в соответствии с настройками шаблона (индивидуально или группой).
- Список статей: В списках статей значения полей отображаются только для тех записей, у которых есть перевод; для остальных поля пусты (нет «наследования» от предыдущей итерации).
- SEO-теги в header: Мета-заголовок и описание успешно заменяются на переведённые версии, что подтверждается просмотром исходного кода страницы.
Заключение
Проделанная работа демонстрирует глубокую интеграцию механизма Extrafields в существующий плагин I18n с соблюдением всех архитектурных принципов Cotonti. Полученное решение может служить образцом для расширения других плагинов дополнительными полями. Все изменения задокументированы и готовы к использованию в production-окружении.
Ушло у меня на реализацию два дня. По накатанной вам сделать это самостоятельно по этой инструкции на может час максимум.
Список файлов, которые редактировались:
1. - i18n.extrafields.php - новый
|- /plugins/i18n/i18n.extrafields.php
2. - i18n.page.php
|- /plugins/i18n/inc/i18n.page.php
3. - i18n.ru.lang.php
|- plugins/i18n/lang/i18n.ru.lang.php
4 - i18n.page.tags.php
|- /plugins/i18n/i18n.page.tags.php
5 - i18n.pagetags.php
|- /plugins/i18n/i18n.pagetags.php
6 - i18n.pagetags.php
|- /plugins/i18n/i18n.pagetags.php
7. - i18n.page.tpl
|- /themes/index36/plugins/i18n/i18n.page.tpl
8. - header.tpl
|- /themes/index36/header.tpl
9. - page.tpl
|- /themes/index36/modules/page/page.tpl
10. - page.list.tpl
; |- /themes/index36/modules/page/page.list.tpl
И так, вот так было:
<!DOCTYPE html>
<!-- header.pages.tpl -->
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The effectiveness of the link in the text of the content or in the widget.</title>
<meta name="description" content="description of the differences between the two methods of placing links on a web page: inserting a link directly into the text of an article and displaying a link through a plugin or widget " Recommended products". The purpose of the document is to explain" />
<meta property="og:title" content="The effectiveness of the link in the text of the content or in the widget.">
<meta property="og:description" content="description of the differences between the two methods of placing links on a web page inserting a link directly into the text of an article and displaying a">
<meta property="og:type" content="article">
<meta property="og:url" content="https://abuyfile.com/en/usersblog/comparison-effectiveness-links-to-page-complete-guide">
<meta property="og:image" content="https://abuyfile.com/attacher/page/765/att_1080.webp">
<meta property="og:image:alt" content="The effectiveness of the link in the text of the content or in the widget.">
<meta property="og:site_name" content="aBuyFile - online marketplace">
<meta property="og:locale" content="en">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="The effectiveness of the link in the text of the content or in the widget.">
<meta name="twitter:description" content="description of the differences between the two methods of placing links on a web page inserting a link directly into the text of an article and displaying a">
<meta name="twitter:image" content="https://abuyfile.com/attacher/page/765/att_1080.webp">
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
Результат всей работы и что получилось:
<!DOCTYPE html>
<!-- header.pages.tpl -->
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Where does a link have greater SEO effectiveness?</title>
<meta name="description" content="A complete guide: Comparing the effectiveness of links in the main content versus the “Recommended Products” section for search engine optimization (SEO)" />
<base href="https://abuyfile.com/" />
<link rel="canonical" href="https://abuyfile.com/en/usersblog/comparison-effectiveness-links-to-page-complete-guide" />
<link rel="alternate" hreflang="en" href="https://abuyfile.com/en/usersblog/comparison-effectiveness-links-to-page-complete-guide">
<link rel="alternate" hreflang="x-default" href="https://abuyfile.com/ru/usersblog/comparison-effectiveness-links-to-page-complete-guide">
<link rel="shortcut icon" href="favicon.ico" />
<link rel="icon" href="themes/2waydeal/img/icon.webp" type="image/svg+xml">
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<meta property="og:title" content="The effectiveness of the link in the text of the content or in the widget.">
<meta property="og:description" content="description of the differences between the two methods of placing links on a web page inserting a link directly into the text of an article and displaying a">
<meta property="og:type" content="article">
<meta property="og:url" content="https://abuyfile.com/en/usersblog/comparison-effectiveness-links-to-page-complete-guide">
<meta property="og:image" content="https://abuyfile.com/attacher/page/765/att_1080.webp">
<meta property="og:image:alt" content="The effectiveness of the link in the text of the content or in the widget.">
<meta property="og:site_name" content="aBuyFile - online marketplace">
<meta property="og:locale" content="en">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="The effectiveness of the link in the text of the content or in the widget.">
<meta name="twitter:description" content="description of the differences between the two methods of placing links on a web page inserting a link directly into the text of an article and displaying a">
<meta name="twitter:image" content="https://abuyfile.com/attacher/page/765/att_1080.webp">
Автор: совместная работа webitproff и AI-ассистента.
Дата: 22 апреля 2026 г.
Актуальный код модификаций плагина на GitHub совместимо с Cotonti 0.9.26 и PHP 8.4.
Комментарии (0)
Автор контента
Оффлайн
Sodium Carbonate
Последняя авторизация: 20.07.2026 00:04
- Страница размещена: 23.04.2026 02:24
- Последнее обновление: 23.04.2026 17:30
- Язык:
English