Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
TwigTranslationExtension
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
8 / 8
21
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilters
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getFunctions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getGlobals
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 translateMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 translateFunction
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isUntranslatedMachineKey
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 jsonDecodeFilter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Translation;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use Twig\Extension\AbstractExtension;
12use Twig\Extension\GlobalsInterface;
13use Twig\TwigFilter;
14use Twig\TwigFunction;
15use Yiisoft\Translator\TranslatorInterface;
16
17/**
18 * Twig Translation Extension.
19 *
20 * Exposes |trans filter, t() function, and locale globals in Twig templates.
21 *
22 * @package App\Core\Translation
23 */
24final class TwigTranslationExtension extends AbstractExtension implements GlobalsInterface
25{
26    /**
27     * TwigTranslationExtension constructor.
28     *
29     * @param TranslatorInterface $translator Yii3 translator service.
30     * @param LocaleContext $localeContext Locale context manager.
31     */
32    public function __construct(
33        private readonly TranslatorInterface $translator,
34        private readonly LocaleContextInterface $localeContext
35    ) {
36    }
37
38    /**
39     * {@inheritdoc}
40     */
41    public function getFilters(): array
42    {
43        return [
44            new TwigFilter('trans', [$this, 'translateMessage']),
45            new TwigFilter('translate', [$this, 'translateMessage']),
46            new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']),
47        ];
48    }
49
50    /**
51     * {@inheritdoc}
52     */
53    public function getFunctions(): array
54    {
55        return [
56            new TwigFunction('t', [$this, 'translateFunction']),
57        ];
58    }
59
60    /**
61     * {@inheritdoc}
62     */
63    public function getGlobals(): array
64    {
65        return [
66            'current_locale' => $this->localeContext->getLocale(),
67            'available_languages' => $this->localeContext->getActiveLanguages(),
68        ];
69    }
70
71    /**
72     * Translates a message using Twig filter syntax.
73     *
74     * @param string|null $message Message key or text.
75     * @param string $category Translation category.
76     * @param array<string, mixed> $parameters Placeholder parameters.
77     * @return string|null Translated string or null if machine key is untranslated.
78     */
79    public function translateMessage(?string $message, string $category = 'app', array $parameters = []): ?string
80    {
81        if ($message === null || $message === '') {
82            return '';
83        }
84
85        $locale = $this->localeContext->getLocale();
86        $translated = $this->translator->translate($message, $parameters, $category, $locale);
87
88        if ($this->isUntranslatedMachineKey($translated, $message)) {
89            return null;
90        }
91
92        return $translated;
93    }
94
95    /**
96     * Translates a message using Twig function syntax.
97     *
98     * @param string $category Translation category.
99     * @param string $message Message key or text.
100     * @param array<string, mixed> $parameters Placeholder parameters.
101     * @return string|null Translated string or null if machine key is untranslated.
102     */
103    public function translateFunction(string $category, string $message, array $parameters = []): ?string
104    {
105        $locale = $this->localeContext->getLocale();
106        $translated = $this->translator->translate($message, $parameters, $category, $locale);
107
108        if ($this->isUntranslatedMachineKey($translated, $message)) {
109            return null;
110        }
111
112        return $translated;
113    }
114
115    /** @var list<string> Common single-word machine action keys that should fall back to default when untranslated. */
116    private const array COMMON_MACHINE_WORDS = [
117        'close', 'save', 'cancel', 'edit', 'delete', 'back', 'create', 'update',
118        'search', 'filter', 'loading', 'confirm', 'actions', 'remove', 'add',
119        'all', 'mine', 'pinned', 'verified', 'verify', 'reply', 'unread', 'read',
120        'send', 'favorite', 'details', 'view', 'copy', 'clear', 'download', 'upload',
121    ];
122
123    /**
124     * Determines whether a message returned as-is represents an untranslated machine key.
125     *
126     * @param string $translated Translated string from translator.
127     * @param string $message Original message ID or string.
128     * @return bool True if the message is an untranslated machine identifier.
129     */
130    private function isUntranslatedMachineKey(string $translated, string $message): bool
131    {
132        if ($translated !== $message || str_contains($message, ' ')) {
133            return false;
134        }
135
136        $isMachineFormat = (bool) preg_match('/^[a-z0-9_.-]+$/i', $message);
137        if (!$isMachineFormat) {
138            return false;
139        }
140
141        return str_contains($message, '.')
142            || str_contains($message, '_')
143            || in_array(strtolower($message), self::COMMON_MACHINE_WORDS, true);
144    }
145
146    /**
147     * Decodes a JSON string into an array or object.
148     *
149     * @param mixed $value JSON string or value.
150     * @param bool  $assoc Return associative array.
151     * @return mixed Decoded array or value.
152     */
153    public function jsonDecodeFilter(mixed $value, bool $assoc = true): mixed
154    {
155        if (is_array($value)) {
156            return $value;
157        }
158        if (!is_string($value) || trim($value) === '') {
159            return [];
160        }
161        $decoded = json_decode($value, $assoc);
162        return $decoded !== null ? $decoded : [];
163    }
164}