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 $paths * @param list $extensions * @return list */ 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 $files * @return array> */ 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>> */ 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 */ 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; } }