Шаблонізатор Twig обробляє назви змінних на етапі компіляції шаблону. Через це конструкція на кшталт {{ var_~language_id }} не дає змінну var_1 чи var_2. Twig сприймає var_ як окрему невизначену змінну, перетворює її на порожній рядок і приєднує до неї значення language_id. У результаті на сторінку виводиться просто «1». Із цією ситуацією стикається кожен, хто пише багатомовні модулі для OpenCart, де контролер передає в шаблон змінні title_1, title_2, entry_name_3 з ідентифікатором мови в назві. Twig має кілька способів зібрати назву змінної або ключ масиву з частин, і кожен із них має свої межі застосування залежно від версії шаблонізатора.
В Twig символ тильда (~) виконує конкатенацію рядків. Обидва операнди перетворюються на рядки, тому 'title_' ~ 5 дає 'title_5', а 'price_' ~ product.id ~ '_old' дає 'price_42_old'. Сам по собі оператор формує лише рядок-значення. Щоб цей рядок став назвою змінної, його потрібно передати в конструкцію, яка звертається до змінної або елемента масиву за ім'ям, обчисленим під час виконання. Таких конструкцій у Twig чотири: функція attribute(), квадратні дужки, динамічний оператор крапки та інтерполяція рядків у поєднанні з першими трьома.
Змінна _context і функція attribute()
Кожен шаблон Twig має службову змінну _context. Це асоціативний масив, у якому лежать усі змінні поточного контексту: передані з контролера, оголошені через {% set %} та глобальні. В OpenCart туди потрапляє весь масив $data, який контролер передає в $this->load->view(). Отже, змінна title_1 доступна як елемент _context з ключем 'title_1'.
Функція attribute() дозволяє звернутися до атрибута, методу або властивості об'єкта чи масиву, коли їхня назва зберігається у змінній або генерується виразом. Поєднання цих двох механізмів і дає класичний запис:
{{ attribute(_context, 'var_' ~ language_id) }}
Типовий приклад із форми налаштувань модуля OpenCart 3, де заголовок зберігається окремо для кожної мови:
{% for language in languages %}
<div class="input-group">
<span class="input-group-addon">
<img src="language/{{ language.code }}/{{ language.code }}.png" title="{{ language.name }}" />
</span>
<input type="text"
name="module_banner_title_{{ language.language_id }}"
value="{{ attribute(_context, 'module_banner_title_' ~ language.language_id) }}"
class="form-control" />
</div>
{% endfor %}
Функція працює з будь-яким масивом або об'єктом, тому _context тут лише окремий випадок. Запис attribute(product, 'name_' ~ language_id) звертається до елемента масиву product, а attribute(order, 'get' ~ field|capitalize) викликає метод об'єкта. Третім аргументом функція приймає масив параметрів для методу: attribute(object, method_name, [arg1, arg2]).
Варто враховувати статус функції в новіших версіях. Функцію attribute оголошено застарілою починаючи з Twig 3.15, а її замінником є оператор крапки, який тепер приймає будь-який вираз у круглих дужках. Водночас функція залишиться доступною в Twig 4.0 для плавнішого переходу. Для шаблонів OpenCart 3, які працюють на Twig 2, це не має жодного значення. У проєктах на Twig 3.15 і вище виклик attribute() генерує deprecation-повідомлення в логах.
Квадратні дужки як універсальний спосіб
Оскільки _context є звичайним PHP-масивом, до нього можна звертатися через квадратні дужки з обчисленим ключем:
{{ _context['var_' ~ language_id] }}
Цей запис еквівалентний виклику attribute() для масивів, коротший і працює в усіх гілках Twig від 1.x до 3.x без жодних попереджень. Квадратні дужки підходять для вкладених структур, де динамічною є лише частина шляху:
{{ module_description[language.language_id].title }}
{{ product_option['option_' ~ option.option_id]['value_' ~ value.id] }}
{{ settings[store_id]['theme_' ~ theme_code ~ '_width'] }}
Перший рядок показує найпоширеніший патерн OpenCart: масив, проіндексований ідентифікатором мови. Саме так ядро OpenCart зберігає описи товарів, категорій та інформаційних сторінок у формах адмінпанелі (product_description[language.language_id].name).
Квадратні дужки надійно працюють із масивами та з об'єктами, які реалізують інтерфейс ArrayAccess. Для виклику методу об'єкта за динамічною назвою потрібна функція attribute() або динамічна крапка.
Динамічний оператор крапки в Twig 3.15+
Починаючи з версії 3.15 оператор крапки приймає вираз у круглих дужках. Запис із _context набуває такого вигляду:
{{ _context.('var_' ~ language_id) }}
{{ product.('name_' ~ language_id) }}
{{ user.('get' ~ field|capitalize)() }}
Документація Twig називає два сценарії для цього синтаксису. Перший стосується атрибутів зі спецсимволами: запис user.('first-name') еквівалентний неробочому user.first-name, де дефіс сприймався б як оператор мінус. Другий сценарій стосується саме динамічних назв, обчислених зі змінної. До версії 3.15 обидва завдання вирішувалися через attribute().
Динамічна крапка поєднується з null-safe оператором ?., доданим у Twig 3.23, який повертає null замість винятку, коли лівий операнд дорівнює null. Запис order?.('shipping_' ~ method) безпечно спрацює, навіть якщо змінна order порожня.
У версії 3.28 механізм поширили на макроси. Раніше виклик макросу, назва якого відома лише під час виконання, вимагав функції attribute(), а оператор крапки макроси не підтримував. Тепер макрос обирається так:
{% import "forms.twig" as forms %}
{% set field = 'text' %}
{{ forms.(field)(name, value) }}
Інтерполяція рядків
Twig підтримує вбудовування виразів у рядки через синтаксис #{...}. Інтерполяція працює виключно в рядках у подвійних лапках. У одинарних лапках #{...} залишається звичайним текстом. Отже, такі два записи дають однаковий результат:
{{ attribute(_context, 'entry_' ~ field) }}
{{ attribute(_context, "entry_#{field}") }}
Інтерполяція зручна, коли назва складається з кількох змінних частин:
{{ _context["module_#{code}_status_#{store_id}"] }}
Усередині #{} допускається будь-який вираз, зокрема фільтри та звернення до атрибутів: "field_#{language.language_id}_#{type|lower}". Для назв з одним змінним фрагментом тильда читається простіше, для назв із трьома й більше фрагментами інтерполяція зменшує кількість лапок і операторів.
Динамічні ключі під час створення масивів
Конкатенація потрібна і в зворотному напрямку, коли шаблон сам формує масив із ключами на зразок title_1. Літерал хеша в Twig сприймає ключ без лапок як рядок, тому { key: value } створює елемент із ключем 'key'. Щоб ключ обчислювався, його беруть у круглі дужки:
{% set titles = {} %}
{% for language in languages %}
{% set titles = titles|merge({ ('title_' ~ language.language_id): language.name }) %}
{% endfor %}
Тут прихована одна особливість фільтра merge. Для масивів він використовує PHP-функцію array_merge, яка перенумеровує цілочисельні ключі. Якщо записати titles|merge({ (language.language_id): language.name }), ключі 1, 2, 3 перетворяться на 0, 1, 2, і зв'язок з ідентифікатором мови зникне. Рядковий префікс на кшталт 'lang_' або 'title_' зберігає ключі в незмінному вигляді.
Змінну з динамічною назвою через {% set %} створити неможливо. Тег set очікує статичне ім'я, тому для таких задач використовують проміжний масив, як у прикладі вище, і звертаються до нього через квадратні дужки.
Перевірка існування та значення за замовчуванням
Динамічна назва може вказувати на змінну, якої немає в контексті. Поведінка Twig у цьому випадку залежить від опції strict_variables. Коли вона вимкнена, звернення до відсутнього ключа повертає null і виводить порожній рядок. Коли ввімкнена, Twig кидає RuntimeError з текстом «Key "var_5" does not exist». Щоб шаблон стабільно працював за будь-якого налаштування, перевірку роблять явно:
{% if attribute(_context, 'title_' ~ language_id) is defined %}
{{ attribute(_context, 'title_' ~ language_id) }}
{% endif %}
{{ _context['title_' ~ language_id]|default('') }}
{{ _context['title_' ~ language_id] ?? heading_title }}
Тест defined вміє перевіряти існування динамічного атрибута, а фільтр default та оператор ?? пригнічують помилку для невизначених ключів. Оператор ?? зручний для каскадних підстановок, коли за відсутності перекладу потрібно показати значення мови за замовчуванням.
Пріоритет операторів у виразах з конкатенацією
Тильда має свій пріоритет відносно арифметичних операторів і ??, і в Twig 3 він відрізняється від запланованого для Twig 4. Вираз 'var_' ~ language_id + 1 у Twig 3.x обчислюється як ('var_' ~ language_id) + 1, тобто намагається додати одиницю до рядка. Використання ~ разом із + чи - без дужок, що уточнюють пріоритет, викликає deprecation починаючи з Twig 3.15, а в Twig 4.0 оператори + та - матимуть вищий пріоритет за ~. Аналогічна зміна стосується оператора ??: запис foo ?? bar ~ baz вважається застарілим, і його слід переписати як (foo ?? bar) ~ baz для поведінки Twig 3 або foo ?? (bar ~ baz) для поведінки Twig 4.
Для динамічних назв це означає просте правило: будь-яку арифметику в ключі беруть у дужки. Запис _context['var_' ~ (language_id + 1)] однаково працює в усіх версіях і не генерує попереджень.
Сумісність у модулях для OpenCart 2.3, 3 та 4
Вибір синтаксису для модуля OpenCart визначається версією Twig, яку постачає ядро. Гілка 3.0.x у своєму composer.json вимагає twig/twig версії ^2.4.8, сучасна гілка OpenCart 4 вимагає twig/twig ^3.3.7, а частина власників магазинів на OpenCart 3 самостійно оновлює Twig до третьої версії заради підтримки PHP 8.x. Розподіл способів за сумісністю виглядає так:
_context['var_' ~ id] працює в Twig 1.x, 2.x і 3.x без попереджень і підходить для модулів, які розповсюджуються під кілька версій OpenCart одночасно. attribute(_context, 'var_' ~ id) працює в усіх версіях і генерує deprecation-повідомлення лише в Twig 3.15 і новіших. _context.('var_' ~ id) вимагає Twig 3.15+ і спричиняє синтаксичну помилку компіляції на OpenCart 3 зі штатним Twig 2. - Динамічний виклик макросу
forms.(name)() вимагає Twig 3.28+.
Найстійкіше рішення для власних модулів полягає в тому, щоб узагалі не створювати змінних із ідентифікатором мови в назві. Контролер формує вкладений масив:
foreach ($languages as $language) {
$language_id = $language['language_id'];
$data['title'][$language_id] = $this->config->get('module_banner_title_' . $language_id);
}
Шаблон звертається до нього звичайними квадратними дужками:
<input type="text"
name="module_banner_title[{{ language.language_id }}]"
value="{{ title[language.language_id] }}"
class="form-control" />
Такий запис компілюється однаково в Twig 2 і Twig 3, не залежить від _context і статусу функції attribute(). Поле форми з назвою module_banner_title[1] PHP отримує як масив, тому модель налаштувань зберігає всі переклади одним записом у таблиці setting з serialized = 1. Конкатенацію назв змінних у цьому підході залишають для випадків, коли структуру даних задає чужий код: ядро OpenCart, сторонній модуль або тема, змінювати яку немає можливості.