From 45e6f06f1cb0b1bf00800cf40f5dcde85dcf080b Mon Sep 17 00:00:00 2001 From: Gianni Guida Date: Mon, 31 Aug 2026 19:38:43 +0200 Subject: [PATCH] fix: resolve translation references in implicitly-loaded fallback catalogues Backport of the 2.x fix to 1.x. Flarum's Translator::getCatalogue() only ran `=>`-reference resolution (parseCatalogue()) when the requested locale was not yet present in $this->catalogues. But Symfony's Translator::loadFallbackCatalogues() pre-stores the *original* fallback catalogue in $this->catalogues while attaching only a fresh copy to the requesting locale's catalogue. So loading e.g. 'de' first stored a raw, never-parsed 'en' original; a later getCatalogue('en') found the locale already set, skipped parsing, and returned it with unresolved references. Consumers that snapshot the catalogue - most damagingly Frontend\AddTranslations, which compiles the locale JS assets - then emitted literal "=> core.admin.dashboard.title" strings, and the poisoned compiled asset persisted until a cache flush (revisions gate recompiles). Track parsed state per catalogue *object* (SplObjectStorage, since 1.x must stay PHP 7.3-compatible) instead of per locale name, and walk the fallback chain on every access. The raw stored original and its parsed copy share a locale name, so a locale-keyed flag cannot distinguish them; per-object tracking parses each catalogue object exactly once. parseCatalogue() is unchanged. Co-Authored-By: Claude Fable 5 --- framework/core/src/Locale/Translator.php | 25 ++++-- .../TranslatorReferenceResolutionTest.php | 84 +++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 framework/core/tests/unit/Locale/TranslatorReferenceResolutionTest.php diff --git a/framework/core/src/Locale/Translator.php b/framework/core/src/Locale/Translator.php index dce685a4c9..8e4043715e 100644 --- a/framework/core/src/Locale/Translator.php +++ b/framework/core/src/Locale/Translator.php @@ -17,6 +17,17 @@ class Translator extends BaseTranslator implements TranslatorContract { const REFERENCE_REGEX = '/^=>\s*([a-z0-9_\-\.]+)$/i'; + /** + * Catalogue objects whose `=> reference` values have already been resolved. + * + * Tracked per object rather than per locale: Symfony stores an original + * catalogue per locale but attaches fresh copies as fallbacks of other + * locales, so several objects can share one locale name. + * + * @var \SplObjectStorage|null + */ + private $parsedCatalogues; + public function get($key, array $replace = [], $locale = null) { return $this->trans($key, $replace, null, $locale); @@ -39,16 +50,16 @@ public function getCatalogue($locale = null) $this->assertValidLocale($locale); } - $parse = ! isset($this->catalogues[$locale]); - $catalogue = parent::getCatalogue($locale); - if ($parse) { - $this->parseCatalogue($catalogue); + if ($this->parsedCatalogues === null) { + $this->parsedCatalogues = new \SplObjectStorage(); + } - $fallbackCatalogue = $catalogue; - while ($fallbackCatalogue = $fallbackCatalogue->getFallbackCatalogue()) { - $this->parseCatalogue($fallbackCatalogue); + for ($current = $catalogue; $current !== null; $current = $current->getFallbackCatalogue()) { + if (! $this->parsedCatalogues->contains($current)) { + $this->parseCatalogue($current); + $this->parsedCatalogues->attach($current); } } diff --git a/framework/core/tests/unit/Locale/TranslatorReferenceResolutionTest.php b/framework/core/tests/unit/Locale/TranslatorReferenceResolutionTest.php new file mode 100644 index 0000000000..1be1cc460a --- /dev/null +++ b/framework/core/tests/unit/Locale/TranslatorReferenceResolutionTest.php @@ -0,0 +1,84 @@ +cacheDir = sys_get_temp_dir().'/flarum-translator-test-'.uniqid('', true); + mkdir($this->cacheDir); + + file_put_contents($this->cacheDir.'/en.yml', "foo: '=> bar'\nbar: Resolved\n"); + file_put_contents($this->cacheDir.'/de.yml', "baz: Beispiel\n"); + } + + protected function tearDown(): void + { + array_map('unlink', glob($this->cacheDir.'/*') ?: []); + rmdir($this->cacheDir); + + parent::tearDown(); + } + + private function translator(): Translator + { + // Wired like LocaleServiceProvider: non-debug, cache dir, 'en' fallback. + $translator = new Translator('de', null, $this->cacheDir, false); + $translator->setFallbackLocales(['en']); + $translator->addLoader('prefixed_yaml', new PrefixedYamlFileLoader()); + $translator->addResource('prefixed_yaml', ['file' => $this->cacheDir.'/en.yml', 'prefix' => ''], 'en', self::DOMAIN); + $translator->addResource('prefixed_yaml', ['file' => $this->cacheDir.'/de.yml', 'prefix' => ''], 'de', self::DOMAIN); + + return $translator; + } + + public function test_references_are_resolved_when_locale_is_loaded_directly() + { + $translator = $this->translator(); + + $this->assertSame('Resolved', $translator->getCatalogue('en')->get('foo', 'messages')); + } + + public function test_references_are_resolved_when_locale_was_first_loaded_as_a_fallback() + { + $translator = $this->translator(); + + // Loading 'de' makes Symfony implicitly load and store the 'en' + // catalogue as its fallback, before 'en' is ever requested directly. + $translator->getCatalogue('de'); + + $this->assertSame('Resolved', $translator->getCatalogue('en')->get('foo', 'messages')); + } + + public function test_references_are_resolved_in_fallback_catalogues() + { + $translator = $this->translator(); + + $fallback = $translator->getCatalogue('de')->getFallbackCatalogue(); + + $this->assertSame('Resolved', $fallback->get('foo', 'messages')); + $this->assertSame('Resolved', $translator->trans('foo', [], null, 'de')); + } +}