Integrating Extrafields into the I18n plugin in Cotonti CMF
A detailed guide to the changes made, describing each file, how it works, and usage examples. The material is intended for developers who want to understand the principles of integrating Extrafields into their own Cotonti plugins.
A Complete Guide to Integrating Extrafields into the I18n Plugin for Cotonti CMF
Introduction
The built-in I18n plugin (Content Internationalization) for Cotonti CMF provides the ability to create multilingual versions of pages and categories. However, it did not originally support the Extrafields (custom fields) mechanism, which is widely used in other Cotonti modules (e.g., the standard Pages module) to dynamically add arbitrary fields to entities without modifying code.
The goal of this work is the complete integration of Extrafields into the I18n plugin I modified, namely:
- Adding support for custom fields for page translations (
cot_i18n_pages). - Implementation of translation add and edit forms with dynamic fields.
- Proper saving and loading of Extrafields values.
- Output of custom field values in templates:
- On the article view page.
- In article lists.
- In the site header (e.g., for SEO tags).
- Providing flexible tools for manual and automatic field display.
As a result, we obtained a full-fledged extension of the I18n plugin functionality, allowing administrators to create any additional fields for translations (text, textareas, dates, files, etc.) and use them on par with regular page fields.
This document is a detailed guide to the changes made, describing each file, the logic of operation, and usage examples. The material is intended for developers who want to understand the principles of Extrafields integration into their own Cotonti plugins. Discussion topic in this forum section.
Part 1. Architecture and Preparation
1.1. Overview of the Extrafields Mechanism in Cotonti
Extrafields in Cotonti are managed through the API described in the system/extrafields.php file. Main functions:
cot_extrafield_add()– registers a new field in thecot_extra_fieldstable and adds a corresponding column to the target table.cot_extrafield_remove()– removes a field.cot_build_extrafields()– generates an HTML form element for entering a field value.cot_import_extrafields()– validates and returns a field value from an HTTP request.cot_build_extrafields_data()– formats a value for display in a template.
In order for a DB table to become available for Extrafields management via the admin panel,
at
https://cotonti.local/ru/admin/extrafields?n=cot_i18n_pages
Site Administration ⇒ Other ⇒ Extrafields ⇒ Table cot_i18n_pages - Page translationsit must be added to the “white list” ($extra_whitelist) and “attached” to the admin.extrafields.first hook.
This is exactly how it is done, for example, in the Pages module (file
modules/page/page.extrafields.php
1.2. Translation Table Structure
The original cot_i18n_pages table contains the following columns:
ipage_id– identifier of the original page.ipage_locale– language code (e.g.,en,ua).ipage_translatorid,ipage_translatorname– translator information.ipage_date,ipage_title,ipage_desc,ipage_text– translated data.
To support Extrafields, the administrator must be able to add new columns to this table via the admin interface. The Extrafields API will automatically create columns with the ipage_ prefix, as required by the plugin logic (similar to page_ for the cot_pages table).
Part 2. Registering the Table in the Extrafields Admin Panel
2.1. File plugins/i18n/i18n.extrafields.php
To make the cot_i18n_pages table appear in the list of tables available for Extrafields management, you need to create a file that hooks into admin.extrafields.first.
Path: plugins/i18n/i18n.extrafields.php
Content:
<?php
/* ====================
[BEGIN_COT_EXT]
Hooks=admin.extrafields.first
[END_COT_EXT]
==================== */
// new file i18n.extrafields.php
// Path: 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}',
]
];
I always read it to myself like this:
create a file (usually, but not always: the extension prefix and the suffix with the name of the hook to which we will attach the logic code of this file).
In the plugin root, create the file i18n.extrafields.php
In the i18n.extrafields.php file, place the logic code:
defined('COT_CODE') or die('Wrong URL.');
require_once cot_incfile('i18n', 'plug');
$extra_whitelist[$db_i18n_pages] = [
.....
];then declare,
<?php
/* ====================
[BEGIN_COT_EXT]
Hooks=admin.extrafields.first
[END_COT_EXT]
==================== */that the code of this logic is “hooked” – connected to the admin.extrafields.first hook
where this hook is “nailed, screwed, fastened, located, so that something can be hung on it”, namely in the file
/system/admin/admin.extrafields.php
and specifically in the section
/* === Hook === */
foreach (cot_getextplugins('admin.extrafields.first') as $pl) {
include $pl;
}
/* ===== */
How this works in Cotonti (hook placement and attaching code to it):
The file where the hook is actually invoked (the hook is placed) with the name'admin.extrafields.first'
and, accordingly, the inclusion of the file with the code we need in i18n.extrafields.php, — is
/system/admin/admin.extrafields.php
The file /system/extrafields.php does not contain hook calls; it is a library of API functions for working with extra fields and is included in admin.extrafields.php via require_once. It does not itself initiate plugin loading.
Thus, the sequence is:admin.php?m=extrafields is opened./system/admin/admin.extrafields.php is loaded.
Inside it, require_once cot_incfile('extrafields'); is executed (which includes /system/extrafields.php).
Then in admin.extrafields.php the hook admin.extrafields.first fires, which includes i18n.extrafields.php (and other extension files).
Specific sequence of file execution and hook operation:
(this describes the principle of hooks in Cotonti, so I write it here as a cheat sheet, especially for beginners)
The user opens the admin panel for extra fields:
URL: admin.php?m=extrafields
The Cotonti system loads the file:
/system/admin/admin.extrafields.php
At the beginning of this file, the following is executed:
require_once cot_incfile('extrafields');
→ this includes /system/extrafields.php (the API functions library).
Then in admin.extrafields.php the $extra_whitelist array is initialized
(initially it contains only $db_structure – categories).
Right after that comes the hook:
foreach (cot_getextplugins('admin.extrafields.first') as $pl) {
include $pl;
}
The system finds all files registered for the admin.extrafields.first hook.
One of them — the new file i18n.extrafields.php (from the i18n plugin folder that we created).
The file i18n.extrafields.php is included right at this spot.
Inside it, the code executes:
$extra_whitelist[$db_i18n_pages] = [ ... ];
Thus, information about the $db_i18n_pages table (page content localization) is added to the global $extra_whitelist array.
After the hook loop finishes, admin.extrafields.php continues working.
Then the list of DB tables for managing Extra fields is built, and pages are now present in that list.
Result:
/system/extrafields.php is loaded first, but it only provides functions and does not affect hooks.
/system/admin/admin.extrafields.php — is the main file that triggers the admin.extrafields.first hook.
i18n.extrafields.php executes at the moment the hook runs, supplementing the list of allowed tables.
Thus, the code from i18n.extrafields.php runs before the admin interface is displayed, but after the loading of core functions and the initial initialization of admin.extrafields.php.
Explanations:
$db_i18n_pages– global variable containing the full table name (e.g.,cot_i18n_pages).caption– localized name, taken from the language file.tags– a hint for the administrator on which tags can be used in templates (in this case, for the translation edit form).
Language file (plugins/i18n/lang/i18n.ru.lang.php) was supplemented with the line:
$L['i18n_pages']='Page translations';After adding this file, the table cot_i18n_pages becomes available in the section “Administration → Extrafields”, and the administrator can create any additional fields for it.
I once goofed and got:
Warning: Undefined array key "i18n_pages"
in /home/..../public_html/plugins/i18n/i18n.extrafields.php on line 16 never be afraid of such errors – there is nothing critical,
we added the localization string $L['i18n_pages'] to the i18n.extrafields file but forgot to define the key itself in the language files.
Open the folder with localization string files
/plugins/i18n/lang/
and in the file for the required language, e.g., i18n.ru.lang.php
add the line
$L['i18n_pages'] = 'Page translations';and do the same for each localization language, defining the key for the element 'caption' => $L['i18n_pages'], in the $extra_whitelist[$db_i18n_pages] = []; array of our i18n.extrafields file.
Part 3. Modifying the Main Translation Script
3.1. File plugins/i18n/inc/i18n.page.php
This file is responsible for adding (a=add), editing (a=edit), and deleting (a=delete) page translations. The changes made ensure loading, display, validation, and saving of Extrafields.
3.1.1. Including the Extrafields API
At the beginning of the file, after the standard require_once
namely, after the line:
require_once cot_incfile('forms');add
// === EXTRAFIELDS: Include custom fields API ===
require_once cot_incfile('extrafields');3.1.2. Loading Field Configuration
Before the line
if ($a == 'add' && empty($pag_i18n)) {add
// === EXTRAFIELDS: Load custom field configuration for the translation table ===
$extrafields = Cot::$extrafields[$db_i18n_pages] ?? [];After defining $pag_i18n (loading the existing translation). This array contains all registered custom fields for the translation table.
3.1.3. Add Mode ($a == 'add')
Next, right after the array (approximately lines 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')
];add the import, insert the code
// === EXTRAFIELDS: Import custom field values from POST ===
foreach ($extrafields as $exfld) {
$fieldName = 'ipage_' . $exfld['field_name'];
$pag_i18n[$fieldName] = cot_import_extrafields(
'ri18n' . $exfld['field_name'],
$exfld,
'P',
'',
'i18n_'
);
}Import and Saving
On POST request, after collecting the main fields, Extrafields are imported:
The cot_import_extrafields() function correctly handles all field types, including files, checkboxes, and dates.
Displaying Fields in the Form
Tags for each field are passed to the template:
after the array:
$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')
]);insert the code
// === EXTRAFIELDS: Generate and pass to the template fields for entering additional data ===
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: Added common EXTRAFLD tags for the template ===
$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');
}
}
Two types of tags are used here:
- Field-specific (
I18N_PAGE_FORM_FIELDNAME). - Common
I18N_PAGE_FORM_EXTRAFLDand_TITLE, which allow outputting all fields in a loop via a single<!-- BEGIN: EXTRAFLD -->block.
3.1.4. Edit Mode ($a == 'edit')
Find the line:
if ($_SERVER['REQUEST_METHOD'] == 'POST') {Before it, add the code:
// === EXTRAFIELDS: Save old data for correct file field handling ===
$pag_i18n_old = $pag_i18n;Saving Old Data
For correct operation of file fields (so that when a file is deleted it is physically removed from the server), it is necessary to save the old values:
$pag_i18n_old=$pag_i18n;
Import on POST
When saving changes, Extrafields are imported with passing the old values:
find the lines (approximately 200):
$pag_i18n['ipage_locale'] = $new_locale;
if (cot_error_found()) {
and exactly between them insert
// === EXTRAFIELDS: Import custom field values from POST, passing old values ===
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_'
);
}Critical save fix
In my modified plugin, updating the record was performed with explicit field listing:
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]
);This approach ignores any additional fields. To save them, you need to pass the entire $pag_i18n array, having first removed the primary key ipage_id from it:
therefore replace this code block with the code below:
unset($pag_i18n['ipage_id']);
// old DB update replaced with new one
Cot::$db->update(Cot::$db->i18n_pages, $pag_i18n, "ipage_id = ? AND ipage_locale = ?", [$id, $i18n_locale]);This allows the database API to automatically update all columns, including dynamically added ones.
Displaying fields in the edit form
Similarly to the add mode, fields are generated taking into account already entered values (including after validation errors). The value is taken either from $pag_i18n or from POST.
find the array for outputting tags to the template:
$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')
]);and immediately after it add the output of our extrafields to the template:
// === EXTRAFIELDS: Generate and pass to the template fields for entering additional data (editing) ===
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: Added common EXTRAFLD tags for the template ===
$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');
}
}
That's it!
at this point, editing of the /plugins/i18n/inc/i18n.page.php file is finished.
The full file on github is here.
Now open the template for filling and editing content translations into other languages
the standard template is located at
/plugins/i18n/tpl/i18n.page.tpl
and add dynamic field output
<!-- ===== ADDITIONAL FIELDS BLOCK (EXTRAFIELDS) ===== -->
<!-- Each field is output in a loop through the EXTRAFLD block -->
<!-- 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 -->
<!-- ===== END OF EXTRAFIELDS BLOCK ===== -->
Part 4. Outputting Extrafields in Page Module Templates
Open the file /plugins/i18n/i18n.pagetags.php
To cut to the chase, find the code:
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,
]
);
}
}replace it with the code below
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: Generate tags for each translation extrafield ===
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 {
// === Reset extrafield tags for pages without translation ===
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 RESET ===
}
Please note that I never use
@deprecated in 0.9.24- deprecated into the trash can
Now, for those interested, let's read into what happens here.
After saving the data, it is necessary to display it to site visitors. Cotonti provides several levels of templating, and we have implemented Extrafields support for all main scenarios.
4.1. Individual Tags via cot_generate_pagetags() (Manual Output)
The cot_generate_pagetags() function is called for each page (in a list or single view) and returns an array of tags with a prefix. The I18n plugin overrides it via the pagetags.main hook.
File: plugins/i18n/inc/i18n.pagetags.php
Inside the condition when the translation exists (!empty($page_data['ipage_title'])), a loop over Extrafields is added:
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;
}
}These tags are then merged with the main $temp_array array and become available in templates with the corresponding prefix.
Example of usage in the page.tpl template:
field names are just for example, used for testing with successful outcome
<!-- 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 -->
where META_DESCRIPTION is the extrafield name in lowercase 'meta_description' when creating extrafields for the i18n plugin
where META_TITLE is the extrafield name in lowercase 'meta_title' when creating extrafields for the i18n pluginsee the screenshot
The duplication problem in lists and its solution (a bit ahead of schedule, but so as not to return here again)
When using in an article list loop (page.list.tpl), XTemplate retains tag values between iterations if they are not overridden. Therefore, for articles without translation, the Extrafields tags contained data from the previous article.
Solution: an else block was added after the if (!empty($page_data['ipage_title'])) check, which resets the tags to empty strings:
if (!empty($page_data['ipage_title'])) {
...
// === END EXTRAFIELDS ===
} else {
// === Reset extrafield tags for pages without translation ===
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 RESET ===
}Now for each article without translation, the fields are guaranteed to be empty.
4.2. Dynamic Output of All Fields on the View Page
To output all fields at once without manual enumeration, the page.tags hook and the <!-- BEGIN: EXTRAFLD --> block are used.
File: plugins/i18n/inc/i18n.page.tags.php
In this file, which is executed after the $t template object has been created,
find the code
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,
]);
}
}and immediately after it insert the following code:
// === Extrafields for i18n pages — output to template ===
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']];
}
// Individual tags (manual output)
$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,
]);
// Dynamic output via EXTRAFLD block
$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');
}
}
The template page.tpl must contain:
for dynamic output
<!-- BEGIN: EXTRAFLD -->
<div class="form-group">
<label>{I18N_EXTRAFIELD_TITLE}</label>
<div>{I18N_EXTRAFIELD_VALUE}</div>
</div>
<!-- END: EXTRAFLD -->or individually, as in the example above with fields
{I18N_META_TITLE} and {I18N_META_DESCRIPTION}During parsing, a separate block will be output for each field.
4.3. Passing Extrafields to the Site Header Template (header.tpl)
Now we come to the most interesting part, which actually started the "fuss" and the movement with extrafields in the content localization plugin — namely, outputting headers and descriptions into page meta tags that are distinct from the standard fields.
Often, SEO fields (meta title, description) need to be output in the <title> and <meta name="description"> tags, which are located in header.tpl. For this, we extended the header.tags hook.
File: plugins/i18n/inc/i18n.header.tags.php
open it, and add at the very end
// ========== 4. PASS TRANSLATION EXTRAFIELDS TO HEADER ==========
if ($env['ext'] == 'page' && isset($id) && $id > 0) {
// Load the translation for the current language, if not already loaded
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])) {
// Load original page data for parser
$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] ?? '';
// Assign tags for header.tpl
$t->assign([
// without escaping
// $tag => cot_build_extrafields_data('i18n', $exfld, $value, $parser),
// escape quotes and other special characters
$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 {
// Reset tags if translation is missing
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' => '',
]);
}
}
}
}my full file /plugins/i18n/i18n.header.tags.php is in the repository
note that we had to apply escaping here, and instead of special characters their codes will be output, for example, instead of " there will be " — this is because in the template we insert {I18N_HEADER_META_DESCRIPTION} directly into the content="" attribute. If the value contains quotes ( " ), this can break the HTML. Therefore, it is better to escape special characters on the PHP side before passing to the template, which we did by replacing the line
$tag => cot_build_extrafields_data('i18n', $exfld, $value, $parser),with
$tag => htmlspecialchars(cot_build_extrafields_data('i18n', $exfld, $value, $parser), ENT_QUOTES, 'UTF-8'),
Usage in header.tpl:
feel free to add
<!-- 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 -->Thus, if a translation with filled Extrafields meta_title and meta_description exists, they replace the standard page meta tags.
Part 5. Database Structure and Examples
5.1. The cot_extra_fields Table
It stores definitions of all additional fields. For our task, two records for cot_i18n_pages were added:
sql
INSERT INTO `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', 'Meta title'),
('cot_i18n_pages', 'meta_description', 'textarea', '<textarea class=\"form-control\" name=\"{$name}\" rows=\"{$rows}\" cols=\"{$cols}\" maxlength=\"255\">{$value}</textarea>', '', '', '', 0, 1, 'HTML', 'Meta description');5.2. The cot_i18n_pages Table
When creating fields through the admin panel, the Extrafields API automatically added the columns ipage_meta_title and ipage_meta_description:
sql
ALTER TABLE `cot_i18n_pages` ADD `ipage_meta_title` VARCHAR(255) DEFAULT '';
ALTER TABLE `cot_i18n_pages` ADD `ipage_meta_description` TEXT;Now the table has the following structure (fragment):
sql
CREATE TABLE `cot_i18n_pages` (
`ipage_id` int UNSIGNED NOT NULL,
`ipage_locale` varchar(8) NOT NULL DEFAULT 'en',
...
`ipage_meta_title` varchar(255) DEFAULT '',
`ipage_meta_description` text,
PRIMARY KEY (`ipage_id`, `ipage_locale`),
...
);5.3. Example of Translation Data with Filled Extrafields
INSERT INTO `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...');Part 6. Templates and Their Modification
Let's go over the templates again, to consolidate everything in one place for the final result.
6.1. Translation Form i18n.page.tpl
Blocks for outputting Extrafields were added to the form:
dynamic output:
<!-- 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. View Page page.tpl
Two approaches are used:
Manual output of specific fields:
<!-- 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 -->
Dynamic output of all fields:
<!-- BEGIN: EXTRAFLD -->
<div class="form-group">
<label>{I18N_EXTRAFIELD_TITLE}</label>
<div>{I18N_EXTRAFIELD_VALUE}</div>
</div>
<!-- END: EXTRAFLD -->6.3. Article List page.list.tpl
For output in the list, tags with the LIST_ROW_I18N_PAGE_... prefix are available.
!!! The file i18n.pagetags.php is responsible
Only individual (manual) specification of fields in the template works
Example:
<!-- 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. Site Header header.tpl
a short version was given above, but here for completeness:
<!--
/********************************************************************************
* 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>Part 7. Results and Verification
After making all the described changes, the I18n plugin received full Extrafields support, comparable to the Pages module.
Verification:
- Admin panel: The
cot_i18n_pagestable appeared in the "Extrafields" section. The administrator can create and edit fields of any type. - Translation form: When adding/editing a translation, all active additional fields are displayed. The entered values are saved correctly.
- Translation page: When viewing a page in a language other than the default, the fields are displayed according to the template settings (individually or as a group).
- Article list: In article lists, field values are displayed only for those records that have a translation; for the rest, the fields are empty (no "inheritance" from the previous iteration).
- SEO tags in header: The meta title and description are successfully replaced with the translated versions, as confirmed by viewing the page source code.
Conclusion
The work carried out demonstrates a deep integration of the Extrafields mechanism into the existing I18n plugin while adhering to all of Cotonti's architectural principles. The resulting solution can serve as a model for extending other plugins with additional fields. All changes are documented and ready for use in a production environment.
It took me two days to implement. Following this guide, you can do it yourself in an hour at most.
List of files that were edited:
1. - i18n.extrafields.php - new
|- /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
And so, this is how it was:
<!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" />
The result of the whole work and what came out:
<!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">
Author: joint work of webitproff and AI assistant.
Date: April 22, 2026.
Actual plugin modification code on GitHub compatible with Cotonti 0.9.26 and PHP 8.4.
Comments (0)
Content author
Offline
Sodium Carbonate
Last logged: 2026-06-18 10:44
- Page published: 2026-04-23 02:24
- Last update: 2026-04-23 17:30
- Language:
Русский