forked from fa/breadcrumb-the-shire
Each module can now ship its own i18n/ directory with translation files (default_de.json, default_en.json). At boot time, ModuleI18nLoader preloads core translations then merges module translations on top via Reflection into I18n::$strings — the t() helper stays unchanged. - Add i18nPath property to ModuleManifest - Add ModuleI18nLoader with Reflection-based preload - Extract 59 module-exclusive keys from core i18n into module files (notifications: 8, bookmarks: 41, addressbook: 10) - Expand TranslationKeysTest to scan modules/ and validate against merged core + module translations - Add ModuleI18nContractTest for per-module de/en parity and completeness Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
237 lines
7.4 KiB
PHP
237 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Tests\I18n;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
|
|
class TranslationKeysTest extends TestCase
|
|
{
|
|
public function testAllTranslationKeysExistInDefaultLocales(): void
|
|
{
|
|
$root = realpath(__DIR__ . '/../..');
|
|
$this->assertNotFalse($root, 'Project root not found.');
|
|
|
|
// Core scan paths
|
|
$corePaths = [
|
|
$root . '/pages',
|
|
$root . '/templates',
|
|
$root . '/lib',
|
|
$root . '/web/js',
|
|
];
|
|
$extensions = ['php', 'phtml', 'js'];
|
|
|
|
// Collect core keys
|
|
$coreKeys = $this->collectKeysFromPaths($corePaths, $extensions);
|
|
|
|
// Load core translations
|
|
$coreTranslations = $this->loadTranslationMap($root . '/i18n', ['default_de.json', 'default_en.json']);
|
|
|
|
// Also load all module translations so core keys provided by modules are available
|
|
$moduleTranslations = $this->loadAllModuleTranslations($root . '/modules');
|
|
$mergedTranslations = [];
|
|
foreach ($coreTranslations as $name => $map) {
|
|
$mergedTranslations[$name] = $map;
|
|
}
|
|
foreach ($moduleTranslations as $name => $maps) {
|
|
foreach ($maps as $locale => $map) {
|
|
$mergedTranslations[$locale] = array_merge($mergedTranslations[$locale] ?? [], $map);
|
|
}
|
|
}
|
|
|
|
// Validate core keys exist in merged (core + module) translations
|
|
$missing = [];
|
|
foreach ($coreKeys as $key) {
|
|
foreach ($mergedTranslations as $name => $map) {
|
|
if (!array_key_exists($key, $map)) {
|
|
$missing[$name][] = $key;
|
|
}
|
|
}
|
|
}
|
|
|
|
$messages = [];
|
|
foreach ($missing as $name => $keys) {
|
|
$messages[] = $name . ' missing: ' . implode(', ', $keys);
|
|
}
|
|
$this->assertSame([], $missing, implode(' | ', $messages));
|
|
}
|
|
|
|
public function testAllModuleTranslationKeysExist(): void
|
|
{
|
|
$root = realpath(__DIR__ . '/../..');
|
|
$this->assertNotFalse($root, 'Project root not found.');
|
|
|
|
$modulesDir = $root . '/modules';
|
|
if (!is_dir($modulesDir)) {
|
|
$this->markTestSkipped('No modules directory found.');
|
|
}
|
|
|
|
$extensions = ['php', 'phtml', 'js'];
|
|
|
|
// Load core translations as fallback
|
|
$coreTranslations = $this->loadTranslationMap($root . '/i18n', ['default_de.json', 'default_en.json']);
|
|
|
|
$allMissing = [];
|
|
|
|
foreach (glob($modulesDir . '/*/module.php') as $manifestFile) {
|
|
$moduleDir = dirname($manifestFile);
|
|
$moduleId = basename($moduleDir);
|
|
|
|
// Scan module directories for keys
|
|
$modulePaths = [
|
|
$moduleDir . '/pages',
|
|
$moduleDir . '/templates',
|
|
$moduleDir . '/lib',
|
|
$moduleDir . '/web',
|
|
];
|
|
$moduleKeys = $this->collectKeysFromPaths($modulePaths, $extensions);
|
|
if ($moduleKeys === []) {
|
|
continue;
|
|
}
|
|
|
|
// Load module translations and merge with core
|
|
$moduleI18nDir = $moduleDir . '/i18n';
|
|
$moduleTranslations = is_dir($moduleI18nDir)
|
|
? $this->loadTranslationMap($moduleI18nDir, ['default_de.json', 'default_en.json'])
|
|
: [];
|
|
|
|
$merged = [];
|
|
foreach ($coreTranslations as $locale => $map) {
|
|
$merged[$locale] = array_merge($map, $moduleTranslations[$locale] ?? []);
|
|
}
|
|
|
|
foreach ($moduleKeys as $key) {
|
|
foreach ($merged as $locale => $map) {
|
|
if (!array_key_exists($key, $map)) {
|
|
$allMissing["modules/{$moduleId} → {$locale}"][] = $key;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$messages = [];
|
|
foreach ($allMissing as $context => $keys) {
|
|
$messages[] = $context . ' missing: ' . implode(', ', $keys);
|
|
}
|
|
$this->assertSame([], $allMissing, implode(' | ', $messages));
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $paths
|
|
* @param list<string> $extensions
|
|
* @return list<string>
|
|
*/
|
|
private function collectKeysFromPaths(array $paths, array $extensions): array
|
|
{
|
|
$usedKeys = [];
|
|
foreach ($paths as $path) {
|
|
if (!is_dir($path)) {
|
|
continue;
|
|
}
|
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));
|
|
foreach ($iterator as $file) {
|
|
if (!$file->isFile()) {
|
|
continue;
|
|
}
|
|
$ext = strtolower((string) $file->getExtension());
|
|
if (!in_array($ext, $extensions, true)) {
|
|
continue;
|
|
}
|
|
$content = file_get_contents($file->getPathname());
|
|
if ($content === false || $content === '') {
|
|
continue;
|
|
}
|
|
foreach ($this->extractTranslationKeys($content) as $key) {
|
|
$usedKeys[$key] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
$keys = array_keys($usedKeys);
|
|
sort($keys);
|
|
|
|
return $keys;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $files
|
|
* @return array<string, array<string, string>>
|
|
*/
|
|
private function loadTranslationMap(string $dir, array $files): array
|
|
{
|
|
$translations = [];
|
|
foreach ($files as $name) {
|
|
$path = $dir . '/' . $name;
|
|
if (!is_file($path)) {
|
|
continue;
|
|
}
|
|
$raw = file_get_contents($path);
|
|
if ($raw === false) {
|
|
continue;
|
|
}
|
|
$json = json_decode($raw, true);
|
|
if (is_array($json)) {
|
|
$translations[$name] = $json;
|
|
}
|
|
}
|
|
|
|
return $translations;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<string, array<string, string>>>
|
|
*/
|
|
private function loadAllModuleTranslations(string $modulesDir): array
|
|
{
|
|
$result = [];
|
|
if (!is_dir($modulesDir)) {
|
|
return $result;
|
|
}
|
|
|
|
foreach (glob($modulesDir . '/*/i18n') as $i18nDir) {
|
|
$moduleId = basename(dirname($i18nDir));
|
|
foreach (['default_de.json', 'default_en.json'] as $file) {
|
|
$path = $i18nDir . '/' . $file;
|
|
if (!is_file($path)) {
|
|
continue;
|
|
}
|
|
$raw = file_get_contents($path);
|
|
if ($raw === false) {
|
|
continue;
|
|
}
|
|
$json = json_decode($raw, true);
|
|
if (is_array($json)) {
|
|
$result[$moduleId][$file] = $json;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function extractTranslationKeys(string $content): array
|
|
{
|
|
$keys = [];
|
|
// Match t('...') with escaped quotes inside, and the same for t("...").
|
|
$patterns = [
|
|
"/\\bt\\s*\\(\\s*'((?:\\\\\\\\.|[^'\\\\\\\\])*)'\\s*\\)/",
|
|
'/\\bt\\s*\\(\\s*"((?:\\\\\\\\.|[^"\\\\\\\\])*)"\\s*\\)/',
|
|
];
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (!preg_match_all($pattern, $content, $matches)) {
|
|
continue;
|
|
}
|
|
foreach ($matches[1] as $match) {
|
|
$keys[] = stripcslashes($match);
|
|
}
|
|
}
|
|
|
|
return $keys;
|
|
}
|
|
}
|