Translator.php
PHP
Path: src/I18n/Translator.php
<?php
namespace mini\I18n;
use mini\Mini;
use mini\Util\QueryParser;
use mini\Util\PathsRegistry;
use MessageFormatter;
/**
* Translator class responsible for loading and managing translations
*
* Handles translation file loading, caching, and fallback logic.
* Automatically creates missing translation entries in the default language files.
*
* Implements TranslatorInterface to allow custom translator implementations.
*/
class Translator implements TranslatorInterface
{
private PathsRegistry $translationsPaths;
private string $defaultLanguage = 'default';
private array $loadedTranslations = [];
private bool $autoCreateDefaults;
private array $pathAliases = [];
public function __construct(PathsRegistry $translationsPaths, bool $autoCreateDefaults = true)
{
$this->translationsPaths = $translationsPaths;
$this->autoCreateDefaults = $autoCreateDefaults;
}
/**
* Add a path alias for translation file resolution
*
* Maps an absolute source path to an alias prefix. When a t() call originates from
* a file under the aliased path, translations are searched under {alias}/ prefix.
*
* Example:
* $translator->addPathAlias('/var/www/vendor/fubber/mini', 'MINI');
*
* A t() call from /var/www/vendor/fubber/mini/src/Invalid.php will search for:
* - _translations/default/MINI/src/Invalid.php.json (application override)
* - vendor/fubber/mini/translations/default/MINI/src/Invalid.php.json (framework)
*
* @param string $absolutePath Absolute path to the source directory
* @param string $alias Alias prefix for translations (e.g., 'MINI', 'MY-PLUGIN')
*/
public function addPathAlias(string $absolutePath, string $alias): void
{
$this->pathAliases[rtrim($absolutePath, '/')] = $alias;
}
/**
* Get current language code from global locale
*/
private function getCurrentLanguageCode(): string
{
return \Locale::getPrimaryLanguage(\Locale::getDefault());
}
/**
* Translate a Translatable instance using ICU MessageFormatter
*/
public function translate(Translatable $translatable): string
{
$sourceText = $translatable->getSourceText();
$sourceFile = $translatable->getSourceFile();
$vars = $translatable->getVars();
// Get translation from files (with conditional support)
$translatedText = $this->getTranslation($sourceFile, $sourceText, $vars);
// Use ICU MessageFormatter for all translations
return $this->formatMessage($translatedText, $vars);
}
/**
* Get translation for a specific source text from a specific file
*/
private function getTranslation(string $sourceFile, string $sourceText, array $vars = []): string
{
// Get default language from Mini singleton
$defaultLanguage = Mini::$mini->defaultLanguage;
// Create simple fallback chain: current -> default -> 'default'
$currentLanguage = $this->getCurrentLanguageCode();
$fallbackChain = [$currentLanguage];
if ($currentLanguage !== $defaultLanguage) {
$fallbackChain[] = $defaultLanguage;
}
if (!in_array('default', $fallbackChain)) {
$fallbackChain[] = 'default';
}
// Determine alias prefix for this source file
$aliasPrefix = $this->getAliasPrefix($sourceFile);
// Try each language in the fallback chain
foreach ($fallbackChain as $langCode) {
$translation = $this->loadTranslationFromFile($langCode, $sourceFile, $sourceText, $vars, $aliasPrefix);
if ($translation !== null) {
return $translation;
}
}
// Auto-create entry in default language file if enabled
if ($this->autoCreateDefaults) {
$this->createDefaultTranslation($sourceFile, $sourceText, $aliasPrefix);
}
// Final fallback: return original text
return $sourceText;
}
/**
* Load translation from a specific language file with alias prefix support
*/
private function loadTranslationFromFile(string $languageCode, string $sourceFile, string $sourceText, array $vars = [], ?string $aliasPrefix = null): ?string
{
// Build translation file path with alias prefix if present
$translationPath = $aliasPrefix ? "{$aliasPrefix}/{$sourceFile}" : $sourceFile;
$translations = $this->getFileTranslations($languageCode, $translationPath, $aliasPrefix);
$translation = $this->extractTranslation($translations, $sourceText, $vars);
return $translation;
}
/**
* Extract translation from loaded translations array with conditional support
*/
private function extractTranslation(array $translations, string $sourceText, array $vars = []): ?string
{
if (!array_key_exists($sourceText, $translations)) {
return null;
}
$translation = $translations[$sourceText];
// Treat null and empty string as "not translated" - fall back to default
if ($translation === null || $translation === '') {
return null;
}
// Handle conditional translations (arrays)
if (is_array($translation)) {
return $this->resolveConditionalTranslation($translation, $vars);
}
// Simple string translation
return $translation;
}
/**
* Resolve conditional translation based on variable values using QueryParser
*/
private function resolveConditionalTranslation(array $conditionalTranslation, array $vars): ?string
{
// Try each condition in order
foreach ($conditionalTranslation as $condition => $translationText) {
// Check if condition matches the variables (before checking default)
if ($condition !== '' && $this->evaluateCondition($condition, $vars)) {
return $translationText;
}
}
// If no specific conditions matched, use default fallback
if (array_key_exists('', $conditionalTranslation)) {
return $conditionalTranslation[''];
}
// If no conditions matched and no default ("") provided, return null
return null;
}
/**
* Evaluate a condition string against variable values using QueryParser
*/
private function evaluateCondition(string $condition, array $vars): bool
{
try {
$queryParser = new QueryParser($condition);
return $queryParser->matches($vars);
} catch (\Exception $e) {
// If parsing fails, treat as no match
return false;
}
}
/**
* Get all translations for a specific language and file with alias prefix support
*/
private function getFileTranslations(string $languageCode, string $sourceFile, ?string $aliasPrefix = null): array
{
$cacheKey = $aliasPrefix ? "{$aliasPrefix}:{$languageCode}:{$sourceFile}" : "{$languageCode}:{$sourceFile}";
if (isset($this->loadedTranslations[$cacheKey])) {
return $this->loadedTranslations[$cacheKey];
}
$filePath = $this->getTranslationFilePath($languageCode, $sourceFile, $aliasPrefix);
if (!file_exists($filePath)) {
$this->loadedTranslations[$cacheKey] = [];
return [];
}
$jsonContent = file_get_contents($filePath);
if ($jsonContent === false) {
// File read error - cache empty array to avoid repeated attempts
$this->loadedTranslations[$cacheKey] = [];
return [];
}
$translations = json_decode($jsonContent, true);
if (!is_array($translations)) {
// JSON decode error or invalid format - cache empty array
$this->loadedTranslations[$cacheKey] = [];
return [];
}
$this->loadedTranslations[$cacheKey] = $translations;
return $translations;
}
/**
* Ensure default translation exists for a source text with alias prefix support
* Creates file if missing, adds string if not present in existing file
*/
private function createDefaultTranslation(string $sourceFile, string $sourceText, ?string $aliasPrefix = null): void
{
$filePath = $this->getTranslationFilePath($this->defaultLanguage, $sourceFile, $aliasPrefix);
// Ensure directory exists
$directory = dirname($filePath);
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
// Check if file exists, if not create it
$fileExists = file_exists($filePath);
// Load existing translations (will be empty array if file doesn't exist)
$translations = $this->getFileTranslations($this->defaultLanguage, $sourceFile, $aliasPrefix);
// Track if we need to update the file
$needsUpdate = false;
if (!$fileExists) {
// File doesn't exist, we'll need to create it
$needsUpdate = true;
} elseif (!isset($translations[$sourceText])) {
// File exists but this string is missing
$needsUpdate = true;
}
if ($needsUpdate) {
// Add new entry
$translations[$sourceText] = $sourceText;
// Write back to file with pretty printing
$jsonContent = json_encode($translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if (file_put_contents($filePath, $jsonContent) !== false) {
// Update cache
$cacheKey = $aliasPrefix ? "{$aliasPrefix}:{$this->defaultLanguage}:{$sourceFile}" : "{$this->defaultLanguage}:{$sourceFile}";
$this->loadedTranslations[$cacheKey] = $translations;
}
}
}
/**
* Get the full path to a translation file with alias prefix support
*
* Uses PathsRegistry to find the first existing translation file, searching in priority order:
* 1. Application translations (_translations/)
* 2. Framework translations (vendor/fubber/mini/translations/)
*/
private function getTranslationFilePath(string $languageCode, string $sourceFile, ?string $aliasPrefix = null): string
{
// Map 'en' (default language) to 'default' folder for backward compatibility
$folderName = ($languageCode === 'en') ? 'default' : $languageCode;
if ($aliasPrefix !== null) {
// Aliased translation files go under {translations}/{lang}/{ALIAS}/{file}.json
$relativePath = "{$folderName}/{$aliasPrefix}/{$sourceFile}.json";
} else {
// Regular app translations go under {translations}/{lang}/{file}.json
$relativePath = "{$folderName}/{$sourceFile}.json";
}
// Try to find the file in registered paths
$foundPath = $this->translationsPaths->findFirst($relativePath);
// If not found, return the path where we would create it (first path in registry)
if ($foundPath === null) {
$paths = $this->translationsPaths->getPaths();
return $paths[0] . '/' . $relativePath;
}
return $foundPath;
}
/**
* Format message using ICU MessageFormatter
*/
private function formatMessage(string $pattern, array $vars): string
{
try {
$formatter = new \MessageFormatter(\Locale::getDefault(), $pattern);
$result = $formatter->format($vars);
if ($result === false) {
return $pattern;
}
return $result;
} catch (\Exception) {
return $pattern;
}
}
/**
* Determine the alias prefix for a source file based on registered path aliases
*
* @param string $sourceFile Relative path from project root
* @return string|null Alias prefix or null for application files
*/
private function getAliasPrefix(string $sourceFile): ?string
{
$projectRoot = Mini::$mini->root;
// Convert relative path to absolute for comparison
$absoluteSourcePath = $projectRoot . '/' . ltrim($sourceFile, '/');
// Check each path alias to see if the source file falls under it
foreach ($this->pathAliases as $basePath => $alias) {
if (str_starts_with($absoluteSourcePath, $basePath . '/')) {
return $alias;
}
}
return null; // No alias (application files)
}
}