From c7d1ab384a821a92819469d08dc64bb233fc33e3 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 10 Nov 2026 16:17:52 +0100 Subject: [PATCH 01/10] feat(translation): resolve Translatable texts of post types and taxonomies on init Every post type and taxonomy reaches WordPress through the entity registration service, from the callback of the init action. The factories and the configuring() support now hand it a TranslatingEntityRegistry, which calls Translator::resolve() on the arguments and names just before the adapter registers them, with the entity's text_domain (else pollora) as the default domain. Arguments without a Translatable are passed on unchanged. Adds the pollora:i18n:extract, :update and :compile commands (the commands of pollora/i18n, without their short aliases), registered in the console only. Requires pollora/i18n, added to composer.json in the next commit. --- .../Domain/Services/HasConfiguringSupport.php | 3 +- .../Providers/ArtisanServiceProvider.php | 24 ++ .../Factories/PostTypeFactory.php | 3 +- .../Factories/TaxonomyFactory.php | 3 +- .../Adapters/TranslatingEntityRegistry.php | 46 ++ .../TranslatingEntityRegistryTest.php | 404 ++++++++++++++++++ 6 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php create mode 100644 tests/Unit/Translation/TranslatingEntityRegistryTest.php diff --git a/src/Discovery/Domain/Services/HasConfiguringSupport.php b/src/Discovery/Domain/Services/HasConfiguringSupport.php index 42915953..d6557a31 100644 --- a/src/Discovery/Domain/Services/HasConfiguringSupport.php +++ b/src/Discovery/Domain/Services/HasConfiguringSupport.php @@ -6,6 +6,7 @@ use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Entity\Application\Service\EntityRegistrationService; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; /** * Trait providing support for the configuring lifecycle hook. @@ -98,7 +99,7 @@ protected function applySmartMerge(object $configuredEntity, array $attributeArg */ protected function registerEntity(object $entity, string $registryClass): void { - $registry = new $registryClass; + $registry = new TranslatingEntityRegistry(new $registryClass); $registrationService = new EntityRegistrationService($registry); $registrationService->registerEntity($entity); } diff --git a/src/Foundation/Providers/ArtisanServiceProvider.php b/src/Foundation/Providers/ArtisanServiceProvider.php index 12249e5c..1fc1f8ae 100644 --- a/src/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Foundation/Providers/ArtisanServiceProvider.php @@ -4,8 +4,12 @@ namespace Pollora\Foundation\Providers; +use Illuminate\Console\Application as Artisan; use Illuminate\Support\ServiceProvider; use Pollora\Foundation\Console\Commands\MakeModelCommand; +use Pollora\I18n\Tooling\Console\CompileCommand; +use Pollora\I18n\Tooling\Console\ExtractCommand; +use Pollora\I18n\Tooling\Console\UpdateCommand; /** * Service provider for artisan commands. @@ -18,6 +22,26 @@ public function register(): void $this->commands([ MakeModelCommand::class, ]); + + $this->registerTranslationCommands(); } } + + /** + * Adds the commands of pollora/i18n (pollora:i18n:extract, :update and :compile) to Artisan. + * + * They are the package's own commands, so Artisan and the vendor/bin/pollora-i18n binary + * accept the same options and print the same report. The short names the package gives + * them for its binary (`extract`, `update`, `compile`) are dropped: they would be bare + * Artisan commands. The Tooling classes load when Artisan starts, never on a web request. + */ + private function registerTranslationCommands(): void + { + Artisan::starting(function (Artisan $artisan): void { + foreach ([new ExtractCommand, new UpdateCommand, new CompileCommand] as $command) { + $command->setAliases([]); + $artisan->add($command); + } + }); + } } diff --git a/src/PostType/Infrastructure/Factories/PostTypeFactory.php b/src/PostType/Infrastructure/Factories/PostTypeFactory.php index dc8d890a..da08970d 100755 --- a/src/PostType/Infrastructure/Factories/PostTypeFactory.php +++ b/src/PostType/Infrastructure/Factories/PostTypeFactory.php @@ -9,6 +9,7 @@ use Pollora\Entity\Application\Service\EntityRegistrationService; use Pollora\Entity\Domain\Model\PostType as EntityPostType; use Pollora\PostType\Domain\Contracts\PostTypeFactoryInterface; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; /** * Implementation of the PostTypeFactory interface. @@ -52,7 +53,7 @@ public function make(string $slug, ?string $singular = null, ?string $plural = n } // Register with WordPress using the entity registration service - $registry = new PostTypeRegistryAdapter; + $registry = new TranslatingEntityRegistry(new PostTypeRegistryAdapter); $registrationService = new EntityRegistrationService($registry); $registrationService->registerEntity($postType); diff --git a/src/Taxonomy/Infrastructure/Factories/TaxonomyFactory.php b/src/Taxonomy/Infrastructure/Factories/TaxonomyFactory.php index e6c636dd..2dca27a4 100755 --- a/src/Taxonomy/Infrastructure/Factories/TaxonomyFactory.php +++ b/src/Taxonomy/Infrastructure/Factories/TaxonomyFactory.php @@ -9,6 +9,7 @@ use Pollora\Entity\Application\Service\EntityRegistrationService; use Pollora\Entity\Domain\Model\Taxonomy as EntityTaxonomy; use Pollora\Taxonomy\Domain\Contracts\TaxonomyFactoryInterface; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; /** * Implementation of the TaxonomyFactory interface. @@ -48,7 +49,7 @@ public function make(string $slug, string|array $objectType, ?string $singular = } // Register with WordPress using the entity registration service - $registry = new TaxonomyRegistryAdapter; + $registry = new TranslatingEntityRegistry(new TaxonomyRegistryAdapter); $registrationService = new EntityRegistrationService($registry); $registrationService->registerEntity($taxonomy); diff --git a/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php new file mode 100644 index 00000000..7cc5d483 --- /dev/null +++ b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php @@ -0,0 +1,46 @@ + $args + * @param array $names + */ + public function register(string $slug, array $args, array $names): void + { + $textDomain = $args['text_domain'] ?? null; + $domain = is_string($textDomain) && $textDomain !== '' ? $textDomain : self::DEFAULT_DOMAIN; + + $this->registry->register( + $slug, + Translator::resolve($args, $domain), + Translator::resolve($names, $domain), + ); + } +} diff --git a/tests/Unit/Translation/TranslatingEntityRegistryTest.php b/tests/Unit/Translation/TranslatingEntityRegistryTest.php new file mode 100644 index 00000000..7151942c --- /dev/null +++ b/tests/Unit/Translation/TranslatingEntityRegistryTest.php @@ -0,0 +1,404 @@ + [], 'words' => []]; + +/** + * @param array $words 'domain|text' or 'domain|context|text' => translation + */ +function i18nWiringCatalogue(array $words): void +{ + $GLOBALS['i18nWiring'] = ['calls' => [], 'words' => $words]; + + $lookup = function (string $key, string $text) { + $GLOBALS['i18nWiring']['calls'][] = $key; + + return $GLOBALS['i18nWiring']['words'][$key] ?? $text; + }; + + Functions\when('get_locale')->justReturn('fr_FR'); + Functions\when('translate')->alias(fn (string $text, string $domain = 'default'): string => $lookup($domain.'|'.$text, $text)); + Functions\when('_x')->alias(fn (string $text, string $context, string $domain = 'default'): string => $lookup($domain.'|'.$context.'|'.$text, $text)); + Container::getInstance()->instance('translator', new class + { + public function get(string $key, mixed $replace = [], ?string $locale = null): string + { + $domain = is_string($replace) ? $replace : 'default'; + $GLOBALS['i18nWiring']['calls'][] = $domain.'|'.$key; + + return $GLOBALS['i18nWiring']['words'][$domain.'|'.$key] ?? $key; + } + }); +} + +/** + * @return list What was asked of the catalogues since i18nWiringCatalogue() + */ +function i18nWiringCalls(): array +{ + return $GLOBALS['i18nWiring']['calls']; +} + +/** + * The registry the entity registration service talks to, which keeps what it was given. + */ +function i18nWiringRecorder(): EntityRegistryPort +{ + return new class implements EntityRegistryPort + { + /** @var array{slug: string, args: array, names: array}|null */ + public ?array $registered = null; + + public function register(string $slug, array $args, array $names): void + { + $this->registered = ['slug' => $slug, 'args' => $args, 'names' => $names]; + } + }; +} + +/** + * Captures the callbacks the entities hook on `init`, to run them when WordPress would. + * + * @return ArrayObject + */ +function i18nWiringInitHooks(): ArrayObject +{ + /** @var ArrayObject $hooks */ + $hooks = new ArrayObject; + + Functions\when('add_action')->alias(function (string $hook, callable $callback) use ($hooks): bool { + if ($hook === 'init') { + $hooks[] = $callback; + } + + return true; + }); + + return $hooks; +} + +/** + * Captures what reaches register_post_type() and register_taxonomy(), behind Extended CPTs. + * + * The library is installed with the framework and is what the entity adapters call first, + * so it runs here against stubs of the WordPress functions it reads. + * + * @return ArrayObject> + */ +function i18nWiringWordPress(): ArrayObject +{ + /** @var ArrayObject> $registered */ + $registered = new ArrayObject; + + Functions\when('get_post_type_object')->justReturn(); + Functions\when('get_taxonomy')->justReturn(); + Functions\stubs([ + 'get_taxonomies' => [], + 'get_post_types' => [], + 'post_type_exists' => false, + 'taxonomy_exists' => false, + 'is_admin' => false, + 'doing_action' => true, + 'did_action' => 1, + ]); + Functions\when('register_post_type')->alias(function (string $slug, array $args) use ($registered): object { + $registered['post_type:'.$slug] = $args; + + return new stdClass; + }); + Functions\when('register_taxonomy')->alias(function (string $slug, mixed $objectType, array $args) use ($registered): object { + $registered['taxonomy:'.$slug] = $args; + + return new stdClass; + }); + + return $registered; +} + +#[PostType('i18n_book', singular: 'Book', plural: 'Books', textDomain: 'my-theme')] +#[AdminCols([ + 'isbn' => ['title' => 'ISBN'], + 'author' => ['title' => new Translatable('Written by')], + 'rating' => ['title' => new Translatable('Rating', context: 'stars'), 'meta_key' => 'rating'], + 'copies' => ['title' => new Translatable('Copies', domain: 'other-plugin')], + 'loans' => ['title' => new TranslatablePlural('One loan', '%d loans', comment: 'Column header')], +])] +final class I18nWiringBook {} + +#[PostType('i18n_plain', singular: 'Plain', plural: 'Plains')] +#[AdminCols(['isbn' => ['title' => 'ISBN']])] +final class I18nWiringPlain {} + +#[PostType('i18n_configured', singular: 'Configured', plural: 'Configureds', textDomain: 'my-theme')] +final class I18nWiringConfigured +{ + public function configuring(EntityPostType $postType): void + { + $postType->setRawArgs(['admin_cols' => ['author' => ['title' => new Translatable('Written by')]]]); + } +} + +/** + * Discovers a post type class and registers it through the real service, factory and registration service. + */ +function i18nWiringDiscover(string $class): void +{ + $discovery = new PostTypeDiscovery(new PostTypeService(new PostTypeFactory, new WordPressPostTypeRegistry)); + $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass($class)), new ReflectionCache); + $discovery->apply(); +} + +/** + * @param ArrayObject $hooks + */ +function i18nWiringRunInit(ArrayObject $hooks): void +{ + foreach ($hooks as $callback) { + $callback(); + } +} + +describe('TranslatingEntityRegistry', function (): void { + it('resolves the texts nested in the arguments before the registry gets them', function (): void { + i18nWiringCatalogue(['my-theme|Written by' => 'Écrit par', 'my-theme|stars|Rating' => 'Note']); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', [ + 'text_domain' => 'my-theme', + 'admin_cols' => ['author' => ['title' => new Translatable('Written by')], 'rating' => ['title' => new Translatable('Rating', context: 'stars')]], + ], ['singular' => 'Book']); + + expect($recorder->registered['args']['admin_cols'])->toBe([ + 'author' => ['title' => 'Écrit par'], + 'rating' => ['title' => 'Note'], + ])->and($recorder->registered['names'])->toBe(['singular' => 'Book']); + }); + + it('resolves the texts of the names too', function (): void { + i18nWiringCatalogue(['my-theme|Book' => 'Livre']); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', ['text_domain' => 'my-theme'], ['singular' => new Translatable('Book')]); + + expect($recorder->registered['names'])->toBe(['singular' => 'Livre']); + }); + + it('turns a plural text into the structure of _n_noop() with the domain of the entity', function (): void { + i18nWiringCatalogue([]); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', ['text_domain' => 'my-theme', 'cols' => ['loans' => new TranslatablePlural('One loan', '%d loans')]], []); + + expect($recorder->registered['args']['cols']['loans'])->toMatchArray([ + 'singular' => 'One loan', + 'plural' => '%d loans', + 'context' => null, + 'domain' => 'my-theme', + ]); + }); + + it('translates in the text domain of the entity, then in pollora, unless the text names its own', function (): void { + i18nWiringCatalogue(['my-theme|Hello' => 'Bonjour thème', 'pollora|Hello' => 'Bonjour pollora', 'other|Hello' => 'Bonjour autre']); + $recorder = i18nWiringRecorder(); + $registry = new TranslatingEntityRegistry($recorder); + $text = fn (?string $domain = null): array => ['title' => new Translatable('Hello', domain: $domain)]; + + $registry->register('a', ['text_domain' => 'my-theme', ...$text()], []); + $fromEntity = $recorder->registered['args']['title']; + $registry->register('b', $text(), []); + $fromDefault = $recorder->registered['args']['title']; + $registry->register('c', ['text_domain' => '', ...$text()], []); + $fromEmpty = $recorder->registered['args']['title']; + $registry->register('d', ['text_domain' => 'my-theme', ...$text('other')], []); + $explicit = $recorder->registered['args']['title']; + + expect([$fromEntity, $fromDefault, $fromEmpty, $explicit])->toBe(['Bonjour thème', 'Bonjour pollora', 'Bonjour pollora', 'Bonjour autre']); + }); + + it('hands over arguments without a Translatable exactly as they were, and asks no catalogue', function (): void { + i18nWiringCatalogue([]); + $recorder = i18nWiringRecorder(); + $args = ['labels' => ['name' => 'Books', 'add_new_item' => 'Add New Book'], 'text_domain' => 'my-theme', 'supports' => ['title'], 'public' => true, 'rewrite' => ['slug' => 'livres']]; + $names = ['singular' => 'Book', 'plural' => 'Books']; + + (new TranslatingEntityRegistry($recorder))->register('book', $args, $names); + + expect($recorder->registered)->toBe(['slug' => 'book', 'args' => $args, 'names' => $names]) + ->and(i18nWiringCalls())->toBe([]); + }); +}); + +describe('registration through the factories, the discovery and configuring()', function (): void { + it('translates a post type argument on init and not before', function (): void { + i18nWiringCatalogue(['my-theme|Written by' => 'Écrit par']); + $hooks = i18nWiringInitHooks(); + $registered = i18nWiringWordPress(); + + (new PostTypeFactory)->make('book', 'Book', 'Books', ['text_domain' => 'my-theme', 'admin_cols' => ['author' => ['title' => new Translatable('Written by')]]]); + + expect(i18nWiringCalls())->toBe([])->and($registered->count())->toBe(0)->and($hooks)->toHaveCount(1); + + i18nWiringRunInit($hooks); + + expect($registered['post_type:book']['admin_cols'])->toBe(['author' => ['title' => 'Écrit par']]) + ->and(i18nWiringCalls())->toBe(['my-theme|Written by']); + }); + + it('translates a taxonomy argument on init and not before', function (): void { + i18nWiringCatalogue(['pollora|Genre filter' => 'Filtre de genre']); + $hooks = i18nWiringInitHooks(); + $registered = i18nWiringWordPress(); + + (new TaxonomyFactory)->make('genre', 'book', 'Genre', 'Genres', ['admin_filter' => ['title' => new Translatable('Genre filter')]]); + + expect(i18nWiringCalls())->toBe([])->and($registered->count())->toBe(0); + + i18nWiringRunInit($hooks); + + expect($registered['taxonomy:genre']['admin_filter'])->toBe(['title' => 'Filtre de genre']); + }); + + it('translates what the attributes of a discovered class declare, with the textDomain of the attribute', function (): void { + i18nWiringCatalogue([ + 'my-theme|Written by' => 'Écrit par', + 'my-theme|stars|Rating' => 'Note', + 'other-plugin|Copies' => 'Exemplaires', + ]); + $hooks = i18nWiringInitHooks(); + $registered = i18nWiringWordPress(); + + i18nWiringDiscover(I18nWiringBook::class); + $before = i18nWiringCalls(); + i18nWiringRunInit($hooks); + $columns = $registered['post_type:i18n_book']['admin_cols']; + + expect($columns['isbn'])->toBe(['title' => 'ISBN']) + ->and($columns['author'])->toBe(['title' => 'Écrit par']) + ->and($columns['rating'])->toBe(['title' => 'Note', 'meta_key' => 'rating']) + ->and($columns['copies'])->toBe(['title' => 'Exemplaires']) + ->and($columns['loans']['title'])->toMatchArray(['singular' => 'One loan', 'plural' => '%d loans', 'domain' => 'my-theme']) + ->and($before)->not->toContain('my-theme|Written by') + ->and(i18nWiringCalls())->toContain('my-theme|Written by'); + }); + + it('translates what a configuring() method sets, with the textDomain of the attribute', function (): void { + i18nWiringCatalogue(['my-theme|Written by' => 'Écrit par']); + $hooks = i18nWiringInitHooks(); + $registered = i18nWiringWordPress(); + + i18nWiringDiscover(I18nWiringConfigured::class); + i18nWiringRunInit($hooks); + + expect($registered['post_type:i18n_configured']['admin_cols'])->toBe(['author' => ['title' => 'Écrit par']]); + }); + + it('keeps the generated labels as the discovery builds them, translated or not', function (): void { + i18nWiringCatalogue(['pollora|Add New %s' => 'Ajouter %s']); + $hooks = i18nWiringInitHooks(); + $registered = i18nWiringWordPress(); + + i18nWiringDiscover(I18nWiringPlain::class); + i18nWiringRunInit($hooks); + $args = $registered['post_type:i18n_plain']; + + expect($args['labels'])->toMatchArray([ + 'name' => 'Plains', + 'singular_name' => 'Plain', + 'add_new' => 'Add New', + 'add_new_item' => 'Ajouter Plain', + 'edit_item' => 'Edit Plain', + 'new_item' => 'New Plain', + 'view_item' => 'View Plain', + 'view_items' => 'View Plains', + 'search_items' => 'Search Plains', + 'not_found' => 'No Plains found', + 'not_found_in_trash' => 'No Plains found in Trash', + 'parent_item_colon' => 'Parent Plain:', + 'all_items' => 'All Plains', + 'archives' => 'Plain Archives', + 'attributes' => 'Plain Attributes', + 'insert_into_item' => 'Insert into Plain', + 'uploaded_to_this_item' => 'Uploaded to this Plain', + ])->and($args['admin_cols'])->toBe(['isbn' => ['title' => 'ISBN']]) + ->and($args['text_domain'])->toBe('pollora'); + }); +}); + +describe('what a web request loads', function (): void { + /** + * Runs a script in a process of its own, like a request, and returns what it printed. + */ + $request = function (string $script): string { + $autoload = dirname(__DIR__, 3).'/vendor/autoload.php'; + $code = 'require '.var_export($autoload, true).'; '.$script; + $output = shell_exec(escapeshellarg(PHP_BINARY).' -d display_errors=1 -r '.escapeshellarg($code).' 2>&1'); + + $lines = array_filter(explode("\n", trim((string) $output)), fn (string $line): bool => ! str_contains($line, 'JIT is incompatible')); + + return trim(implode("\n", $lines)); + }; + + it('loads the translator and the registry and nothing from the tooling when it registers an entity', function () use ($request): void { + $output = $request(<<<'PHP' + $port = new class implements Pollora\Entity\Port\Out\EntityRegistryPort { public function register(string $slug, array $args, array $names): void {} }; + (new Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry($port))->register('book', ['labels' => ['name' => 'Books'], 'text_domain' => 'x'], ['singular' => 'Book']); + $loaded = array_values(array_filter(get_declared_classes(), fn ($class) => str_starts_with($class, 'Pollora\\I18n\\') || str_starts_with($class, 'Symfony\\Component\\Console\\') || str_starts_with($class, 'PhpParser\\'))); + echo json_encode($loaded); + PHP); + + expect($output)->toBe('["Pollora\\\\I18n\\\\Runtime\\\\Translator"]'); + }); + + it('does not load the tooling when the service providers register, outside the console', function () use ($request): void { + $output = $request(<<<'PHP' + putenv('APP_RUNNING_IN_CONSOLE=false'); + $app = new Illuminate\Foundation\Application(sys_get_temp_dir()); + (new Pollora\Foundation\Providers\ArtisanServiceProvider($app))->register(); + echo json_encode([$app->runningInConsole(), count(array_filter(get_declared_classes(), fn ($class) => str_starts_with($class, 'Pollora\\I18n\\')))]); + PHP); + + expect($output)->toBe('[false,0]'); + }); + + it('registers the commands of the package under their pollora names only, when Artisan starts', function (): void { + $app = new Application(sys_get_temp_dir()); + $app->instance('files', new Filesystem); + (new ArtisanServiceProvider($app))->register(); + + $artisan = new Illuminate\Console\Application($app, new Dispatcher($app), '1.0'); + Illuminate\Console\Application::forgetBootstrappers(); + + expect($artisan->has('pollora:i18n:extract'))->toBeTrue() + ->and($artisan->has('pollora:i18n:update'))->toBeTrue() + ->and($artisan->has('pollora:i18n:compile'))->toBeTrue() + ->and($artisan->has('extract'))->toBeFalse() + ->and($artisan->has('update'))->toBeFalse() + ->and($artisan->has('compile'))->toBeFalse(); + }); +}); From a4826f7534cfe221c90a4216e46f89bfd04014ca Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 10 Nov 2026 17:08:46 +0100 Subject: [PATCH 02/10] feat(translation): build the default labels of post types and taxonomies on init The discovery used to build the labels of a post type or a taxonomy while Laravel boots, before `init`, with the singular and plural names as written in the attribute. The domains of themes and plugins were not loaded yet, and WordPress's translation functions were not available, so the templates ("Add New %s") were never looked up in a catalogue and the names never were. The discovery now hands over the raw names and asks for the default labels with TranslatingEntityRegistry::defaultLabels(). The registry, which runs in the callback of `init`, translates the singular and plural names in the text domain of the entity (`textDomain`, else `pollora`), translates each template, then gives it the translated name. Labels written by the class (#[Labels], withArgs(), configuring(), factory arguments) still win over the generated ones, and their Translatable texts are resolved. Without a catalogue the labels registered are the ones registered before. A domain with no catalogue and no `gettext` filter is not asked for the names. --- .../Services/PostTypeDiscovery.php | 48 +-- .../Services/TaxonomyDiscovery.php | 47 +-- .../Adapters/TranslatingEntityRegistry.php | 195 ++++++++++- tests/Unit/PostType/PostTypeDiscoveryTest.php | 9 +- tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php | 9 +- tests/Unit/Translation/DefaultLabelsTest.php | 326 ++++++++++++++++++ .../TranslatingEntityRegistryTest.php | 2 + 7 files changed, 532 insertions(+), 104 deletions(-) create mode 100644 tests/Unit/Translation/DefaultLabelsTest.php diff --git a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php index de149d40..5feb9a7b 100644 --- a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php +++ b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php @@ -19,6 +19,7 @@ use Pollora\Entity\Adapter\Out\WordPress\PostTypeRegistryAdapter; use Pollora\Entity\Domain\Model\PostType as EntityPostType; use Pollora\PostType\Domain\Contracts\PostTypeServiceInterface; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionMethod; @@ -231,7 +232,7 @@ private function buildBaseConfiguration(string $className, PostType $postType): $textDomain = $postType->textDomain ?? 'pollora'; $initialArgs = [ - 'labels' => $this->generateLabels($singular, $plural, $textDomain), + 'labels' => TranslatingEntityRegistry::defaultLabels(TranslatingEntityRegistry::POST_TYPE, $singular, $plural), 'text_domain' => $textDomain, ]; @@ -414,51 +415,6 @@ private function generatePlural(?string $attributePlural, string $singular): str return Str::plural($singular); } - /** - * Generate WordPress labels array from singular and plural names. - * - * @param string $singular The singular name - * @param string $plural The plural name - * @return array The labels array - */ - private function generateLabels(string $singular, string $plural, string $textDomain = 'pollora'): array - { - return [ - 'name' => $plural, - 'singular_name' => $singular, - /* translators: %s: post type general name (plural) */ - 'add_new' => __('Add New', $textDomain), - /* translators: %s: post type singular name */ - 'add_new_item' => sprintf(__('Add New %s', $textDomain), $singular), - /* translators: %s: post type singular name */ - 'edit_item' => sprintf(__('Edit %s', $textDomain), $singular), - /* translators: %s: post type singular name */ - 'new_item' => sprintf(__('New %s', $textDomain), $singular), - /* translators: %s: post type singular name */ - 'view_item' => sprintf(__('View %s', $textDomain), $singular), - /* translators: %s: post type general name (plural) */ - 'view_items' => sprintf(__('View %s', $textDomain), $plural), - /* translators: %s: post type general name (plural) */ - 'search_items' => sprintf(__('Search %s', $textDomain), $plural), - /* translators: %s: post type general name (plural) */ - 'not_found' => sprintf(__('No %s found', $textDomain), $plural), - /* translators: %s: post type general name (plural) */ - 'not_found_in_trash' => sprintf(__('No %s found in Trash', $textDomain), $plural), - /* translators: %s: post type singular name */ - 'parent_item_colon' => sprintf(__('Parent %s:', $textDomain), $singular), - /* translators: %s: post type general name (plural) */ - 'all_items' => sprintf(__('All %s', $textDomain), $plural), - /* translators: %s: post type singular name */ - 'archives' => sprintf(__('%s Archives', $textDomain), $singular), - /* translators: %s: post type singular name */ - 'attributes' => sprintf(__('%s Attributes', $textDomain), $singular), - /* translators: %s: post type singular name */ - 'insert_into_item' => sprintf(__('Insert into %s', $textDomain), $singular), - /* translators: %s: post type singular name */ - 'uploaded_to_this_item' => sprintf(__('Uploaded to this %s', $textDomain), $singular), - ]; - } - /** * {@inheritDoc} */ diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php index c5f4880e..bb80f219 100644 --- a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php +++ b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php @@ -19,6 +19,7 @@ use Pollora\Entity\Adapter\Out\WordPress\TaxonomyRegistryAdapter; use Pollora\Entity\Domain\Model\Taxonomy as EntityTaxonomy; use Pollora\Taxonomy\Domain\Contracts\TaxonomyServiceInterface; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionMethod; @@ -236,7 +237,7 @@ private function buildBaseConfiguration(string $className, Taxonomy $taxonomy): $textDomain = $taxonomy->textDomain ?? 'pollora'; $initialArgs = [ - 'labels' => $this->generateLabels($singular, $plural, $textDomain), + 'labels' => TranslatingEntityRegistry::defaultLabels(TranslatingEntityRegistry::TAXONOMY, $singular, $plural), 'text_domain' => $textDomain, ]; @@ -419,50 +420,6 @@ private function generatePlural(?string $attributePlural, string $singular): str return Str::plural($singular); } - /** - * Generate WordPress labels array from singular and plural names. - * - * @param string $singular The singular name - * @param string $plural The plural name - * @return array The labels array - */ - private function generateLabels(string $singular, string $plural, string $textDomain = 'pollora'): array - { - return [ - 'name' => $plural, - 'singular_name' => $singular, - 'menu_name' => $plural, - /* translators: %s: taxonomy general name (plural) */ - 'all_items' => sprintf(__('All %s', $textDomain), $plural), - /* translators: %s: taxonomy singular name */ - 'edit_item' => sprintf(__('Edit %s', $textDomain), $singular), - /* translators: %s: taxonomy singular name */ - 'view_item' => sprintf(__('View %s', $textDomain), $singular), - /* translators: %s: taxonomy singular name */ - 'update_item' => sprintf(__('Update %s', $textDomain), $singular), - /* translators: %s: taxonomy singular name */ - 'add_new_item' => sprintf(__('Add New %s', $textDomain), $singular), - /* translators: %s: taxonomy singular name */ - 'new_item_name' => sprintf(__('New %s Name', $textDomain), $singular), - /* translators: %s: taxonomy general name (plural) */ - 'search_items' => sprintf(__('Search %s', $textDomain), $plural), - /* translators: %s: taxonomy general name (plural) */ - 'popular_items' => sprintf(__('Popular %s', $textDomain), $plural), - /* translators: %s: taxonomy general name (plural) */ - 'separate_items_with_commas' => sprintf(__('Separate %s with commas', $textDomain), $plural), - /* translators: %s: taxonomy general name (plural) */ - 'add_or_remove_items' => sprintf(__('Add or remove %s', $textDomain), $plural), - /* translators: %s: taxonomy general name (plural) */ - 'choose_from_most_used' => sprintf(__('Choose from the most used %s', $textDomain), $plural), - /* translators: %s: taxonomy general name (plural) */ - 'not_found' => sprintf(__('No %s found', $textDomain), $plural), - /* translators: %s: taxonomy singular name */ - 'parent_item' => sprintf(__('Parent %s', $textDomain), $singular), - /* translators: %s: taxonomy singular name */ - 'parent_item_colon' => sprintf(__('Parent %s:', $textDomain), $singular), - ]; - } - /** * {@inheritDoc} */ diff --git a/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php index 7cc5d483..6a5308c0 100644 --- a/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php +++ b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php @@ -8,26 +8,53 @@ use Pollora\I18n\Runtime\Translator; /** - * Resolves the translatable texts of an entity just before WordPress registers it. + * Translates an entity just before WordPress registers it. * * The entity registration service calls its registry from the callback of the * `init` action, never earlier, so the text domains of themes and plugins are - * loaded by then. This is the one place where the framework calls - * Translator::resolve(): every post type and taxonomy goes through it, whether - * it comes from a discovered class, the factory or a configuring() method. + * loaded by then. This is the one place where the framework translates what a + * post type or a taxonomy declares: + * + * - the Translatable and TranslatablePlural of its arguments, through Translator::resolve(); + * - for the classes the discovery finds, the singular and plural names, and the default + * labels built from them ("Add New %s" is translated first, then given the translated name). + * + * The discovery does not build labels: it asks for them with defaultLabels(), as the + * only entry of the `labels` argument. Labels written by the class (#[Labels], + * withArgs(), configuring()) are merged over the default ones, as they always were; a + * class whose `labels` replace that entry gets none. An entity that did not ask for + * default labels is registered with the arguments it has, resolved. * * An argument without a Translatable or a TranslatablePlural comes back as it - * was, so a project that uses none of them registers exactly what it did before. + * was, and the names are not looked up when nothing could translate them. */ final readonly class TranslatingEntityRegistry implements EntityRegistryPort { public const string DEFAULT_DOMAIN = 'pollora'; + public const string POST_TYPE = 'post_type'; + + public const string TAXONOMY = 'taxonomy'; + + /** The entry of the `labels` argument that asks for the default labels. */ + public const string DEFAULT_LABELS = '_pollora_default_labels'; + public function __construct(private EntityRegistryPort $registry) {} /** - * The text domain of a Translatable that names none is the `text_domain` - * of the entity (the `textDomain` of its attribute), else `pollora`. + * What the discovery puts in the `labels` argument to have the default labels built on init. + * + * @param self::POST_TYPE|self::TAXONOMY $kind + * @return array + */ + public static function defaultLabels(string $kind, string $singular, string $plural): array + { + return [self::DEFAULT_LABELS => [$kind, $singular, $plural]]; + } + + /** + * The text domain of a Translatable that names none, and of the default labels, + * is the `text_domain` of the entity (the `textDomain` of its attribute), else `pollora`. * * @param array $args * @param array $names @@ -37,10 +64,164 @@ public function register(string $slug, array $args, array $names): void $textDomain = $args['text_domain'] ?? null; $domain = is_string($textDomain) && $textDomain !== '' ? $textDomain : self::DEFAULT_DOMAIN; + if (isset($args['labels'][self::DEFAULT_LABELS])) { + [$args, $names] = $this->withDefaultLabels($args, $names, $domain); + } + $this->registry->register( $slug, Translator::resolve($args, $domain), Translator::resolve($names, $domain), ); } + + /** + * @param array $args + * @param array $names + * @return array{array, array} + */ + private function withDefaultLabels(array $args, array $names, string $domain): array + { + $labels = $args['labels']; + [$kind, $rawSingular, $rawPlural] = $labels[self::DEFAULT_LABELS]; + unset($labels[self::DEFAULT_LABELS]); + + $translating = ! $this->hasNothingToLookUp($domain); + $singular = $translating ? $this->translateName($rawSingular, $domain) : $rawSingular; + $plural = $translating ? $this->translateName($rawPlural, $domain) : $rawPlural; + + $args['labels'] = array_merge( + $kind === self::TAXONOMY + ? $this->taxonomyLabels($singular, $plural, $domain) + : $this->postTypeLabels($singular, $plural, $domain), + $labels, + ); + + // A name another hand changed (configuring()) is left as it is. + if (($names['singular'] ?? null) === $rawSingular) { + $names['singular'] = $singular; + } + + if (($names['plural'] ?? null) === $rawPlural) { + $names['plural'] = $plural; + } + + return [$args, $names]; + } + + /** + * Whether __() would give the text back: no catalogue for the domain and nothing on the `gettext` filter. + * + * Reads what WordPress keeps in memory. A domain with no catalogue is a NOOP_Translations + * (the site in its source language, or a theme with no file for the locale), and stays one + * after the first lookup, where is_textdomain_loaded() would say true. A plugin that + * translates through the `gettext` filter still gets its lookups. + */ + private function hasNothingToLookUp(string $domain): bool + { + return function_exists('get_translations_for_domain') + && is_a(get_translations_for_domain($domain), 'NOOP_Translations') + && ! has_filter('gettext'); + } + + /** + * A name written in the source language, translated in the text domain of the entity. + */ + private function translateName(string $name, string $domain): string + { + return $name === '' || ! function_exists('__') ? $name : __($name, $domain); + } + + /** + * The default labels of a post type, from its singular and plural names. + * + * The templates are literal __() calls so that `wp i18n make-pot` still finds them. + * + * @param string $singular The singular name + * @param string $plural The plural name + * @return array The labels array + */ + private function postTypeLabels(string $singular, string $plural, string $textDomain): array + { + return [ + 'name' => $plural, + 'singular_name' => $singular, + /* translators: %s: post type general name (plural) */ + 'add_new' => __('Add New', $textDomain), + /* translators: %s: post type singular name */ + 'add_new_item' => sprintf(__('Add New %s', $textDomain), $singular), + /* translators: %s: post type singular name */ + 'edit_item' => sprintf(__('Edit %s', $textDomain), $singular), + /* translators: %s: post type singular name */ + 'new_item' => sprintf(__('New %s', $textDomain), $singular), + /* translators: %s: post type singular name */ + 'view_item' => sprintf(__('View %s', $textDomain), $singular), + /* translators: %s: post type general name (plural) */ + 'view_items' => sprintf(__('View %s', $textDomain), $plural), + /* translators: %s: post type general name (plural) */ + 'search_items' => sprintf(__('Search %s', $textDomain), $plural), + /* translators: %s: post type general name (plural) */ + 'not_found' => sprintf(__('No %s found', $textDomain), $plural), + /* translators: %s: post type general name (plural) */ + 'not_found_in_trash' => sprintf(__('No %s found in Trash', $textDomain), $plural), + /* translators: %s: post type singular name */ + 'parent_item_colon' => sprintf(__('Parent %s:', $textDomain), $singular), + /* translators: %s: post type general name (plural) */ + 'all_items' => sprintf(__('All %s', $textDomain), $plural), + /* translators: %s: post type singular name */ + 'archives' => sprintf(__('%s Archives', $textDomain), $singular), + /* translators: %s: post type singular name */ + 'attributes' => sprintf(__('%s Attributes', $textDomain), $singular), + /* translators: %s: post type singular name */ + 'insert_into_item' => sprintf(__('Insert into %s', $textDomain), $singular), + /* translators: %s: post type singular name */ + 'uploaded_to_this_item' => sprintf(__('Uploaded to this %s', $textDomain), $singular), + ]; + } + + /** + * The default labels of a taxonomy, from its singular and plural names. + * + * The templates are literal __() calls so that `wp i18n make-pot` still finds them. + * + * @param string $singular The singular name + * @param string $plural The plural name + * @return array The labels array + */ + private function taxonomyLabels(string $singular, string $plural, string $textDomain): array + { + return [ + 'name' => $plural, + 'singular_name' => $singular, + 'menu_name' => $plural, + /* translators: %s: taxonomy general name (plural) */ + 'all_items' => sprintf(__('All %s', $textDomain), $plural), + /* translators: %s: taxonomy singular name */ + 'edit_item' => sprintf(__('Edit %s', $textDomain), $singular), + /* translators: %s: taxonomy singular name */ + 'view_item' => sprintf(__('View %s', $textDomain), $singular), + /* translators: %s: taxonomy singular name */ + 'update_item' => sprintf(__('Update %s', $textDomain), $singular), + /* translators: %s: taxonomy singular name */ + 'add_new_item' => sprintf(__('Add New %s', $textDomain), $singular), + /* translators: %s: taxonomy singular name */ + 'new_item_name' => sprintf(__('New %s Name', $textDomain), $singular), + /* translators: %s: taxonomy general name (plural) */ + 'search_items' => sprintf(__('Search %s', $textDomain), $plural), + /* translators: %s: taxonomy general name (plural) */ + 'popular_items' => sprintf(__('Popular %s', $textDomain), $plural), + /* translators: %s: taxonomy general name (plural) */ + 'separate_items_with_commas' => sprintf(__('Separate %s with commas', $textDomain), $plural), + /* translators: %s: taxonomy general name (plural) */ + 'add_or_remove_items' => sprintf(__('Add or remove %s', $textDomain), $plural), + /* translators: %s: taxonomy general name (plural) */ + 'choose_from_most_used' => sprintf(__('Choose from the most used %s', $textDomain), $plural), + /* translators: %s: taxonomy general name (plural) */ + 'not_found' => sprintf(__('No %s found', $textDomain), $plural), + /* translators: %s: taxonomy singular name */ + 'parent_item' => sprintf(__('Parent %s', $textDomain), $singular), + /* translators: %s: taxonomy singular name */ + 'parent_item_colon' => sprintf(__('Parent %s:', $textDomain), $singular), + ]; + } } diff --git a/tests/Unit/PostType/PostTypeDiscoveryTest.php b/tests/Unit/PostType/PostTypeDiscoveryTest.php index be45aed2..1a0a3cde 100644 --- a/tests/Unit/PostType/PostTypeDiscoveryTest.php +++ b/tests/Unit/PostType/PostTypeDiscoveryTest.php @@ -16,6 +16,7 @@ use Pollora\Hook\Domain\Contract\Action as ActionService; use Pollora\PostType\Domain\Contracts\PostTypeServiceInterface; use Pollora\PostType\Infrastructure\Services\PostTypeDiscovery; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Spatie\StructureDiscoverer\Data\DiscoveredClass; #[PostType('fixture_book', singular: 'Book', plural: 'Books')] @@ -87,11 +88,13 @@ function (string $slug, string $singular, string $plural, array $args, int $prio ->and($registered['args'])->not->toHaveKey('priority'); }); -it('merges declared labels into the generated ones', function (): void { +it('hands the declared labels over with a request for the default ones, built on init', function (): void { $labels = registeredPostType(DiscoveredBook::class)['args']['labels']; - expect($labels['menu_name'])->toBe('Library') - ->and($labels['singular_name'])->toBe('Book'); + expect($labels)->toBe([ + TranslatingEntityRegistry::DEFAULT_LABELS => [TranslatingEntityRegistry::POST_TYPE, 'Book', 'Books'], + 'menu_name' => 'Library', + ]); }); it('turns method attributes into callbacks on the declaring class', function (): void { diff --git a/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php index c025d307..6bf25e60 100644 --- a/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php +++ b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php @@ -13,6 +13,7 @@ use Pollora\Hook\Domain\Contract\Action as ActionService; use Pollora\Taxonomy\Domain\Contracts\TaxonomyServiceInterface; use Pollora\Taxonomy\Infrastructure\Services\TaxonomyDiscovery; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Spatie\StructureDiscoverer\Data\DiscoveredClass; #[Taxonomy('fixture_genre', singular: 'Genre', plural: 'Genres', objectType: 'book')] @@ -61,12 +62,14 @@ function (string $slug, string|array $objectType, string $singular, string $plur return $registered; } -it('registers the arguments its attributes declare, labels merged', function (): void { +it('registers the arguments its attributes declare, labels left for init', function (): void { $args = registeredTaxonomyArgs(DiscoveredGenre::class); expect($args)->toMatchArray(['hierarchical' => true, 'show_in_rest' => true]) - ->and($args['labels']['menu_name'])->toBe('Shelves') - ->and($args['labels']['singular_name'])->toBe('Genre'); + ->and($args['labels'])->toBe([ + TranslatingEntityRegistry::DEFAULT_LABELS => [TranslatingEntityRegistry::TAXONOMY, 'Genre', 'Genres'], + 'menu_name' => 'Shelves', + ]); }); it('turns a method attribute into a callback on the declaring class', function (): void { diff --git a/tests/Unit/Translation/DefaultLabelsTest.php b/tests/Unit/Translation/DefaultLabelsTest.php new file mode 100644 index 00000000..7d2cc6e0 --- /dev/null +++ b/tests/Unit/Translation/DefaultLabelsTest.php @@ -0,0 +1,326 @@ + What the discovery built at boot for a post type Book/Books. */ +function i18nLabelsBookPostType(): array +{ + return [ + 'name' => 'Books', + 'singular_name' => 'Book', + 'add_new' => 'Add New', + 'add_new_item' => 'Add New Book', + 'edit_item' => 'Edit Book', + 'new_item' => 'New Book', + 'view_item' => 'View Book', + 'view_items' => 'View Books', + 'search_items' => 'Search Books', + 'not_found' => 'No Books found', + 'not_found_in_trash' => 'No Books found in Trash', + 'parent_item_colon' => 'Parent Book:', + 'all_items' => 'All Books', + 'archives' => 'Book Archives', + 'attributes' => 'Book Attributes', + 'insert_into_item' => 'Insert into Book', + 'uploaded_to_this_item' => 'Uploaded to this Book', + ]; +} + +/** @return array What the discovery built at boot for a taxonomy Genre/Genres. */ +function i18nLabelsGenreTaxonomy(): array +{ + return [ + 'name' => 'Genres', + 'singular_name' => 'Genre', + 'menu_name' => 'Genres', + 'all_items' => 'All Genres', + 'edit_item' => 'Edit Genre', + 'view_item' => 'View Genre', + 'update_item' => 'Update Genre', + 'add_new_item' => 'Add New Genre', + 'new_item_name' => 'New Genre Name', + 'search_items' => 'Search Genres', + 'popular_items' => 'Popular Genres', + 'separate_items_with_commas' => 'Separate Genres with commas', + 'add_or_remove_items' => 'Add or remove Genres', + 'choose_from_most_used' => 'Choose from the most used Genres', + 'not_found' => 'No Genres found', + 'parent_item' => 'Parent Genre', + 'parent_item_colon' => 'Parent Genre:', + ]; +} + +#[PostType('lbl_plain', singular: 'Book', plural: 'Books')] +final class I18nLabelsPlain {} + +#[PostType('lbl_partial', singular: 'Book', plural: 'Books')] +#[Labels(menuName: 'Library', addNew: 'Create')] +final class I18nLabelsPartial {} + +#[PostType('lbl_themed', singular: 'Book', plural: 'Books', textDomain: 'my-theme')] +final class I18nLabelsThemed {} + +#[PostType('lbl_replaced', singular: 'Book', plural: 'Books')] +#[Labels(menuName: 'Library')] +final class I18nLabelsReplaced +{ + /** @return array */ + public function withArgs(): array + { + return ['labels' => ['name' => 'Only this']]; + } +} + +#[PostType('lbl_configured', singular: 'Book', plural: 'Books')] +final class I18nLabelsConfigured +{ + public function configuring(EntityPostType $postType): void + { + $postType->setRawArgs(['labels' => ['not_found' => 'Nothing here']]); + } +} + +#[Taxonomy('lbl_genre', singular: 'Genre', plural: 'Genres', objectType: 'book')] +final class I18nLabelsGenre {} + +#[Taxonomy('lbl_themed_genre', singular: 'Genre', plural: 'Genres', objectType: 'book', textDomain: 'my-theme')] +final class I18nLabelsThemedGenre {} + +/** + * Discovers a class, runs the init callbacks and returns what WordPress was given for its labels. + * + * @return array + */ +function i18nLabelsRegistered(string $class, string $kind = 'post_type'): array +{ + $hooks = i18nWiringInitHooks(); + $registered = i18nWiringWordPress(); + $location = new DiscoveryLocation('Tests\\', __DIR__); + $structure = DiscoveredClass::fromReflection(new ReflectionClass($class)); + + if ($kind === 'taxonomy') { + $discovery = new TaxonomyDiscovery(new TaxonomyService(new TaxonomyFactory, new WordPressTaxonomyRegistry)); + } else { + $discovery = new PostTypeDiscovery(new PostTypeService(new PostTypeFactory, new WordPressPostTypeRegistry)); + } + + $discovery->discover($location, $structure, new ReflectionCache); + $discovery->apply(); + i18nWiringRunInit($hooks); + + return $registered[$kind.':'.(new ReflectionClass($class))->getAttributes()[0]->newInstance()->slug]['labels']; +} + +/** + * The labels of the entity, on the keys the default labels cover: what Extended CPTs adds on top is left out. + * + * @param array $labels + * @param array $covering + * @return array + */ +function i18nLabelsRestrictedTo(array $labels, array $covering): array +{ + return array_intersect_key($labels, $covering); +} + +describe('default labels without a catalogue', function (): void { + it('are those of a post type with its singular and plural only', function (): void { + i18nWiringCatalogue([]); + + $labels = i18nLabelsRegistered(I18nLabelsPlain::class); + + expect(i18nLabelsRestrictedTo($labels, i18nLabelsBookPostType()))->toBe(i18nLabelsBookPostType()); + }); + + it('are those of a taxonomy', function (): void { + i18nWiringCatalogue([]); + + $labels = i18nLabelsRegistered(I18nLabelsGenre::class, 'taxonomy'); + + expect(i18nLabelsRestrictedTo($labels, i18nLabelsGenreTaxonomy()))->toEqual(i18nLabelsGenreTaxonomy()); + }); + + it('give way to the labels the class declares, whichever are missing', function (): void { + i18nWiringCatalogue([]); + + $labels = i18nLabelsRegistered(I18nLabelsPartial::class); + + expect($labels['menu_name'])->toBe('Library') + ->and($labels['add_new'])->toBe('Create') + ->and($labels['add_new_item'])->toBe('Add New Book') + ->and($labels['all_items'])->toBe('All Books') + ->and($labels)->not->toHaveKey(TranslatingEntityRegistry::DEFAULT_LABELS); + }); + + it('are not built when withArgs() replaces the labels, as before', function (): void { + i18nWiringCatalogue([]); + + $labels = i18nLabelsRegistered(I18nLabelsReplaced::class); + + expect($labels['name'])->toBe('Only this') + ->and($labels)->not->toHaveKey(TranslatingEntityRegistry::DEFAULT_LABELS); + }); + + it('are completed by the labels configuring() sets', function (): void { + i18nWiringCatalogue([]); + + $labels = i18nLabelsRegistered(I18nLabelsConfigured::class); + + expect($labels['not_found'])->toBe('Nothing here') + ->and($labels['edit_item'])->toBe('Edit Book'); + }); + + it('use the templates of the text domain of the entity', function (): void { + i18nWiringCatalogue([]); + + i18nLabelsRegistered(I18nLabelsThemed::class); + + expect(array_unique(array_map(fn (string $call): string => explode('|', $call)[0], i18nWiringCalls())))->toBe(['my-theme']); + }); + + it('are built by the registry itself for the exact arrays the discovery gives', function (): void { + i18nWiringCatalogue([]); + $recorder = i18nWiringRecorder(); + + $registry = new TranslatingEntityRegistry($recorder); + $registry->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'text_domain' => 'pollora'], ['singular' => 'Book', 'plural' => 'Books']); + + $postType = $recorder->registered; + $registry->register('genre', ['labels' => TranslatingEntityRegistry::defaultLabels('taxonomy', 'Genre', 'Genres'), 'text_domain' => 'pollora'], ['singular' => 'Genre', 'plural' => 'Genres']); + + expect($postType['args']['labels'])->toBe(i18nLabelsBookPostType()) + ->and($recorder->registered['args']['labels'])->toBe(i18nLabelsGenreTaxonomy()) + ->and($recorder->registered['names'])->toBe(['singular' => 'Genre', 'plural' => 'Genres']); + }); +}); + +describe('default labels with a catalogue', function (): void { + it('translate the names and the templates on init, and not at boot', function (): void { + i18nWiringCatalogue(['my-theme|Book' => 'Livre', 'my-theme|Books' => 'Livres', 'my-theme|Add New %s' => 'Ajouter un nouveau %s', 'my-theme|All %s' => 'Tous les %s']); + $hooks = i18nWiringInitHooks(); + $registered = i18nWiringWordPress(); + + $discovery = new PostTypeDiscovery(new PostTypeService(new PostTypeFactory, new WordPressPostTypeRegistry)); + $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(I18nLabelsThemed::class)), new ReflectionCache); + $discovery->apply(); + + expect(i18nWiringCalls())->toBe([]); + + i18nWiringRunInit($hooks); + $labels = $registered['post_type:lbl_themed']['labels']; + + expect($labels['name'])->toBe('Livres') + ->and($labels['singular_name'])->toBe('Livre') + ->and($labels['add_new_item'])->toBe('Ajouter un nouveau Livre') + ->and($labels['all_items'])->toBe('Tous les Livres') + ->and($labels['edit_item'])->toBe('Edit Livre'); + }); + + it('translate the names of a taxonomy, in pollora unless the attribute names a domain', function (): void { + i18nWiringCatalogue(['pollora|Genre' => 'Genre litteraire', 'my-theme|Genre' => 'Style']); + + $default = i18nLabelsRegistered(I18nLabelsGenre::class, 'taxonomy'); + $themed = i18nLabelsRegistered(I18nLabelsThemedGenre::class, 'taxonomy'); + + expect($default['singular_name'])->toBe('Genre litteraire') + ->and($default['edit_item'])->toBe('Edit Genre litteraire') + ->and($themed['singular_name'])->toBe('Style'); + }); + + it('translate the names handed to WordPress alongside the labels', function (): void { + i18nWiringCatalogue(['pollora|Book' => 'Livre', 'pollora|Books' => 'Livres']); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')], ['singular' => 'Book', 'plural' => 'Books', 'slug' => 'livres']); + + expect($recorder->registered['names'])->toBe(['singular' => 'Livre', 'plural' => 'Livres', 'slug' => 'livres']); + }); + + it('leave a name another hand changed as it is', function (): void { + i18nWiringCatalogue(['pollora|Book' => 'Livre']); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')], ['singular' => 'Volume', 'plural' => 'Books']); + + expect($recorder->registered['names']['singular'])->toBe('Volume'); + }); + + it('resolve a Translatable in the labels the class declares', function (): void { + i18nWiringCatalogue(['pollora|Library' => 'Bibliotheque']); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', [ + 'labels' => [...TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'menu_name' => new Translatable('Library')], + ], []); + + expect($recorder->registered['args']['labels']['menu_name'])->toBe('Bibliotheque'); + }); + + it('do not look the names up in a domain with no catalogue, and still ask for the templates', function (): void { + i18nWiringCatalogue([]); + Functions\when('get_translations_for_domain')->justReturn(new NOOP_Translations); + Functions\when('has_filter')->justReturn(false); + + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'text_domain' => 'my-theme'], ['singular' => 'Book', 'plural' => 'Books']); + + expect(i18nWiringCalls())->not->toContain('my-theme|Book') + ->and(i18nWiringCalls())->not->toContain('my-theme|Books') + ->and(i18nWiringCalls())->toContain('my-theme|Add New %s') + ->and($recorder->registered['args']['labels'])->toBe(i18nLabelsBookPostType()) + ->and($recorder->registered['names'])->toBe(['singular' => 'Book', 'plural' => 'Books']); + }); + + it('look the names up in a domain with no catalogue when a plugin translates through the gettext filter', function (): void { + i18nWiringCatalogue([]); + Functions\when('get_translations_for_domain')->justReturn(new NOOP_Translations); + Functions\when('has_filter')->justReturn(10); + + (new TranslatingEntityRegistry(i18nWiringRecorder()))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'text_domain' => 'my-theme'], ['singular' => 'Book', 'plural' => 'Books']); + + expect(i18nWiringCalls())->toContain('my-theme|Book')->toContain('my-theme|Books'); + }); + + it('look each name up once, and no template more than the discovery did', function (): void { + i18nWiringCatalogue([]); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')], ['singular' => 'Book', 'plural' => 'Books']); + $calls = i18nWiringCalls(); + + expect(array_count_values($calls)['pollora|Book'])->toBe(1) + ->and(array_count_values($calls)['pollora|Books'])->toBe(1) + ->and($calls)->toHaveCount(2 + 15); + }); +}); diff --git a/tests/Unit/Translation/TranslatingEntityRegistryTest.php b/tests/Unit/Translation/TranslatingEntityRegistryTest.php index 7151942c..d63a42dc 100644 --- a/tests/Unit/Translation/TranslatingEntityRegistryTest.php +++ b/tests/Unit/Translation/TranslatingEntityRegistryTest.php @@ -43,6 +43,8 @@ function i18nWiringCatalogue(array $words): void }; Functions\when('get_locale')->justReturn('fr_FR'); + Functions\when('get_translations_for_domain')->justReturn(new stdClass); + Functions\when('has_filter')->justReturn(false); Functions\when('translate')->alias(fn (string $text, string $domain = 'default'): string => $lookup($domain.'|'.$text, $text)); Functions\when('_x')->alias(fn (string $text, string $context, string $domain = 'default'): string => $lookup($domain.'|'.$context.'|'.$text, $text)); Container::getInstance()->instance('translator', new class From ffcc18b3bff6a84a8b8bdfe563835a91418fae23 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 12 Nov 2026 09:01:36 +0100 Subject: [PATCH 03/10] feat(translation): translate the texts attributes write on init, and take the label templates from pollora The texts a post type or taxonomy attribute writes in the registration arguments (#[Labels], Label, Description, TitlePlaceholder, FeaturedImage and the titles of the admin columns) are looked up on init in the text domain of the entity (`textDomain`, else `pollora`). A text with no translation comes back as written, so a label written directly in the language of the site is unchanged. The default labels already built, and the Translatable already resolved, are not looked up again. Nothing is looked up in a domain that has no catalogue and no `gettext` filter. The generic templates of the default labels ("Add New %s", "All %s"...) belong to the framework: they are always looked up in the `pollora` domain, whatever the `textDomain` of the entity, and given the singular or plural name translated in the domain of the entity. The `pollora` catalogue was loaded by the service provider once WordPress was up, which on a web request is after `init`, so the templates found no catalogue. The registry now loads it, once per locale, before building the default labels, and the provider reuses that call. A test reads the fields the attributes declare translatable and checks that each of them is translated on registration, and that nothing undeclared is. --- src/Providers/PolloraServiceProvider.php | 18 +- .../Adapters/TranslatingEntityRegistry.php | 185 ++++++--- tests/Unit/Translation/DefaultLabelsTest.php | 161 +++++++- .../TranslatedAttributeTextsTest.php | 350 ++++++++++++++++++ .../TranslatingEntityRegistryTest.php | 3 + 5 files changed, 649 insertions(+), 68 deletions(-) create mode 100644 tests/Unit/Translation/TranslatedAttributeTextsTest.php diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index 1965d8cf..e00862ee 100755 --- a/src/Providers/PolloraServiceProvider.php +++ b/src/Providers/PolloraServiceProvider.php @@ -39,6 +39,7 @@ use Pollora\Schedule\SchedulerServiceProvider; use Pollora\Taxonomy\Infrastructure\Providers\TaxonomyServiceProvider; use Pollora\Theme\Infrastructure\Providers\ThemeServiceProvider; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Pollora\VersionCheck\Infrastructure\Providers\VersionCheckServiceProvider; use Pollora\View\Infrastructure\Providers\TemplateHierarchyServiceProvider; use Pollora\View\ViewServiceProvider; @@ -156,26 +157,13 @@ public function boot(): void } /** - * Load the 'pollora' text domain for framework translations. + * Load the 'pollora' text domain for framework translations, unless the default labels already did. * * Checks for user-provided translations in `wp-content/languages/pollora/` * first, then falls back to the framework's bundled translations. */ private function loadPolloraTextDomain(): void { - if (! function_exists('load_textdomain')) { - return; - } - - $locale = determine_locale(); - $moFile = sprintf('pollora-%s.mo', $locale); - - // User-provided translations take priority - $loaded = load_textdomain('pollora', WP_LANG_DIR.'/pollora/'.$moFile); - - // Fall back to bundled translations - if (! $loaded) { - load_textdomain('pollora', dirname(__DIR__, 2).'/resources/languages/'.$moFile); - } + TranslatingEntityRegistry::loadFrameworkTextDomain(); } } diff --git a/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php index 6a5308c0..8c36966c 100644 --- a/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php +++ b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php @@ -16,17 +16,21 @@ * post type or a taxonomy declares: * * - the Translatable and TranslatablePlural of its arguments, through Translator::resolve(); - * - for the classes the discovery finds, the singular and plural names, and the default - * labels built from them ("Add New %s" is translated first, then given the translated name). + * - for the classes the discovery finds, the texts the attributes write in the registration + * arguments (the labels, `label`, `description`, `title_placeholder`, `featured_image` and + * the titles of the admin columns), each one looked up in the text domain of the entity; + * - for the same classes, the singular and plural names, and the default labels built from + * them. A template ("Add New %s") belongs to the framework, so it is always looked up in the + * `pollora` domain, then given the name translated in the domain of the entity. * * The discovery does not build labels: it asks for them with defaultLabels(), as the * only entry of the `labels` argument. Labels written by the class (#[Labels], * withArgs(), configuring()) are merged over the default ones, as they always were; a - * class whose `labels` replace that entry gets none. An entity that did not ask for - * default labels is registered with the arguments it has, resolved. + * class whose `labels` replace that entry gets none and no text of it is looked up. An + * entity that did not ask for default labels is registered with the arguments it has, resolved. * - * An argument without a Translatable or a TranslatablePlural comes back as it - * was, and the names are not looked up when nothing could translate them. + * A text with no translation comes back as it was, so a label written directly in the + * language of the site stays. Nothing is looked up when nothing could translate it. */ final readonly class TranslatingEntityRegistry implements EntityRegistryPort { @@ -39,6 +43,13 @@ /** The entry of the `labels` argument that asks for the default labels. */ public const string DEFAULT_LABELS = '_pollora_default_labels'; + /** + * The registration arguments, besides `labels` and the titles of `admin_cols`, whose text an attribute writes. + * + * @var list + */ + public const array TRANSLATED_ARGUMENTS = ['label', 'description', 'title_placeholder', 'featured_image']; + public function __construct(private EntityRegistryPort $registry) {} /** @@ -53,7 +64,43 @@ public static function defaultLabels(string $kind, string $singular, string $plu } /** - * The text domain of a Translatable that names none, and of the default labels, + * Loads the catalogue of the framework, the `pollora` domain, unless it is loaded. + * + * The service provider loads it once WordPress is up, which is after `init` on a web request, + * and the default labels are built on `init`: they ask for it first. Translations written by the + * site in `WP_LANG_DIR/pollora/` win over the ones bundled with the framework. + */ + public static function loadFrameworkTextDomain(): void + { + if (! function_exists('load_textdomain') || ! function_exists('determine_locale')) { + return; + } + + $loaded = $GLOBALS['l10n'][self::DEFAULT_DOMAIN] ?? null; + + if ($loaded !== null && ! is_a($loaded, 'NOOP_Translations')) { + return; + } + + $locale = determine_locale(); + + // Once per locale and request: a site in its source language has no file to find. + static $attempted = []; + + if (isset($attempted[$locale])) { + return; + } + + $attempted[$locale] = true; + $moFile = sprintf('pollora-%s.mo', $locale); + + if (! load_textdomain(self::DEFAULT_DOMAIN, WP_LANG_DIR.'/pollora/'.$moFile)) { + load_textdomain(self::DEFAULT_DOMAIN, dirname(__DIR__, 4).'/resources/languages/'.$moFile); + } + } + + /** + * The text domain of a Translatable that names none, and of the texts of the entity, * is the `text_domain` of the entity (the `textDomain` of its attribute), else `pollora`. * * @param array $args @@ -65,7 +112,7 @@ public function register(string $slug, array $args, array $names): void $domain = is_string($textDomain) && $textDomain !== '' ? $textDomain : self::DEFAULT_DOMAIN; if (isset($args['labels'][self::DEFAULT_LABELS])) { - [$args, $names] = $this->withDefaultLabels($args, $names, $domain); + [$args, $names] = $this->withTranslatedTexts($args, $names, $domain); } $this->registry->register( @@ -80,20 +127,26 @@ public function register(string $slug, array $args, array $names): void * @param array $names * @return array{array, array} */ - private function withDefaultLabels(array $args, array $names, string $domain): array + private function withTranslatedTexts(array $args, array $names, string $domain): array { + self::loadFrameworkTextDomain(); + $labels = $args['labels']; [$kind, $rawSingular, $rawPlural] = $labels[self::DEFAULT_LABELS]; unset($labels[self::DEFAULT_LABELS]); $translating = ! $this->hasNothingToLookUp($domain); - $singular = $translating ? $this->translateName($rawSingular, $domain) : $rawSingular; - $plural = $translating ? $this->translateName($rawPlural, $domain) : $rawPlural; + $singular = $translating ? $this->translateText($rawSingular, $domain) : $rawSingular; + $plural = $translating ? $this->translateText($rawPlural, $domain) : $rawPlural; + + if ($translating) { + [$args, $labels] = $this->withTranslatedExplicitTexts($args, $labels, $domain); + } $args['labels'] = array_merge( $kind === self::TAXONOMY - ? $this->taxonomyLabels($singular, $plural, $domain) - : $this->postTypeLabels($singular, $plural, $domain), + ? $this->taxonomyLabels($singular, $plural) + : $this->postTypeLabels($singular, $plural), $labels, ); @@ -109,6 +162,38 @@ private function withDefaultLabels(array $args, array $names, string $domain): a return [$args, $names]; } + /** + * The texts an attribute wrote, which are in the language of the code: each is looked up once, in the domain of the entity. + * + * @param array $args + * @param array $labels The labels other than the default ones + * @return array{array, array} + */ + private function withTranslatedExplicitTexts(array $args, array $labels, string $domain): array + { + foreach ($labels as $key => $label) { + if (is_string($label)) { + $labels[$key] = $this->translateText($label, $domain); + } + } + + foreach (self::TRANSLATED_ARGUMENTS as $key) { + if (isset($args[$key]) && is_string($args[$key])) { + $args[$key] = $this->translateText($args[$key], $domain); + } + } + + if (isset($args['admin_cols']) && is_array($args['admin_cols'])) { + foreach ($args['admin_cols'] as $column => $configuration) { + if (is_array($configuration) && isset($configuration['title']) && is_string($configuration['title'])) { + $args['admin_cols'][$column]['title'] = $this->translateText($configuration['title'], $domain); + } + } + } + + return [$args, $labels]; + } + /** * Whether __() would give the text back: no catalogue for the domain and nothing on the `gettext` filter. * @@ -125,103 +210,105 @@ private function hasNothingToLookUp(string $domain): bool } /** - * A name written in the source language, translated in the text domain of the entity. + * A text written in the source language, translated in the given text domain. */ - private function translateName(string $name, string $domain): string + private function translateText(string $text, string $domain): string { - return $name === '' || ! function_exists('__') ? $name : __($name, $domain); + return $text === '' || ! function_exists('__') ? $text : __($text, $domain); } /** * The default labels of a post type, from its singular and plural names. * - * The templates are literal __() calls so that `wp i18n make-pot` still finds them. + * The templates are literal __() calls in the `pollora` domain, whatever the domain of the entity, + * so that `wp i18n make-pot` finds them; the names given to sprintf() are the translated ones. * * @param string $singular The singular name * @param string $plural The plural name * @return array The labels array */ - private function postTypeLabels(string $singular, string $plural, string $textDomain): array + private function postTypeLabels(string $singular, string $plural): array { return [ 'name' => $plural, 'singular_name' => $singular, /* translators: %s: post type general name (plural) */ - 'add_new' => __('Add New', $textDomain), + 'add_new' => __('Add New', 'pollora'), /* translators: %s: post type singular name */ - 'add_new_item' => sprintf(__('Add New %s', $textDomain), $singular), + 'add_new_item' => sprintf(__('Add New %s', 'pollora'), $singular), /* translators: %s: post type singular name */ - 'edit_item' => sprintf(__('Edit %s', $textDomain), $singular), + 'edit_item' => sprintf(__('Edit %s', 'pollora'), $singular), /* translators: %s: post type singular name */ - 'new_item' => sprintf(__('New %s', $textDomain), $singular), + 'new_item' => sprintf(__('New %s', 'pollora'), $singular), /* translators: %s: post type singular name */ - 'view_item' => sprintf(__('View %s', $textDomain), $singular), + 'view_item' => sprintf(__('View %s', 'pollora'), $singular), /* translators: %s: post type general name (plural) */ - 'view_items' => sprintf(__('View %s', $textDomain), $plural), + 'view_items' => sprintf(__('View %s', 'pollora'), $plural), /* translators: %s: post type general name (plural) */ - 'search_items' => sprintf(__('Search %s', $textDomain), $plural), + 'search_items' => sprintf(__('Search %s', 'pollora'), $plural), /* translators: %s: post type general name (plural) */ - 'not_found' => sprintf(__('No %s found', $textDomain), $plural), + 'not_found' => sprintf(__('No %s found', 'pollora'), $plural), /* translators: %s: post type general name (plural) */ - 'not_found_in_trash' => sprintf(__('No %s found in Trash', $textDomain), $plural), + 'not_found_in_trash' => sprintf(__('No %s found in Trash', 'pollora'), $plural), /* translators: %s: post type singular name */ - 'parent_item_colon' => sprintf(__('Parent %s:', $textDomain), $singular), + 'parent_item_colon' => sprintf(__('Parent %s:', 'pollora'), $singular), /* translators: %s: post type general name (plural) */ - 'all_items' => sprintf(__('All %s', $textDomain), $plural), + 'all_items' => sprintf(__('All %s', 'pollora'), $plural), /* translators: %s: post type singular name */ - 'archives' => sprintf(__('%s Archives', $textDomain), $singular), + 'archives' => sprintf(__('%s Archives', 'pollora'), $singular), /* translators: %s: post type singular name */ - 'attributes' => sprintf(__('%s Attributes', $textDomain), $singular), + 'attributes' => sprintf(__('%s Attributes', 'pollora'), $singular), /* translators: %s: post type singular name */ - 'insert_into_item' => sprintf(__('Insert into %s', $textDomain), $singular), + 'insert_into_item' => sprintf(__('Insert into %s', 'pollora'), $singular), /* translators: %s: post type singular name */ - 'uploaded_to_this_item' => sprintf(__('Uploaded to this %s', $textDomain), $singular), + 'uploaded_to_this_item' => sprintf(__('Uploaded to this %s', 'pollora'), $singular), ]; } /** * The default labels of a taxonomy, from its singular and plural names. * - * The templates are literal __() calls so that `wp i18n make-pot` still finds them. + * The templates are literal __() calls in the `pollora` domain, whatever the domain of the entity, + * so that `wp i18n make-pot` finds them; the names given to sprintf() are the translated ones. * * @param string $singular The singular name * @param string $plural The plural name * @return array The labels array */ - private function taxonomyLabels(string $singular, string $plural, string $textDomain): array + private function taxonomyLabels(string $singular, string $plural): array { return [ 'name' => $plural, 'singular_name' => $singular, 'menu_name' => $plural, /* translators: %s: taxonomy general name (plural) */ - 'all_items' => sprintf(__('All %s', $textDomain), $plural), + 'all_items' => sprintf(__('All %s', 'pollora'), $plural), /* translators: %s: taxonomy singular name */ - 'edit_item' => sprintf(__('Edit %s', $textDomain), $singular), + 'edit_item' => sprintf(__('Edit %s', 'pollora'), $singular), /* translators: %s: taxonomy singular name */ - 'view_item' => sprintf(__('View %s', $textDomain), $singular), + 'view_item' => sprintf(__('View %s', 'pollora'), $singular), /* translators: %s: taxonomy singular name */ - 'update_item' => sprintf(__('Update %s', $textDomain), $singular), + 'update_item' => sprintf(__('Update %s', 'pollora'), $singular), /* translators: %s: taxonomy singular name */ - 'add_new_item' => sprintf(__('Add New %s', $textDomain), $singular), + 'add_new_item' => sprintf(__('Add New %s', 'pollora'), $singular), /* translators: %s: taxonomy singular name */ - 'new_item_name' => sprintf(__('New %s Name', $textDomain), $singular), + 'new_item_name' => sprintf(__('New %s Name', 'pollora'), $singular), /* translators: %s: taxonomy general name (plural) */ - 'search_items' => sprintf(__('Search %s', $textDomain), $plural), + 'search_items' => sprintf(__('Search %s', 'pollora'), $plural), /* translators: %s: taxonomy general name (plural) */ - 'popular_items' => sprintf(__('Popular %s', $textDomain), $plural), + 'popular_items' => sprintf(__('Popular %s', 'pollora'), $plural), /* translators: %s: taxonomy general name (plural) */ - 'separate_items_with_commas' => sprintf(__('Separate %s with commas', $textDomain), $plural), + 'separate_items_with_commas' => sprintf(__('Separate %s with commas', 'pollora'), $plural), /* translators: %s: taxonomy general name (plural) */ - 'add_or_remove_items' => sprintf(__('Add or remove %s', $textDomain), $plural), + 'add_or_remove_items' => sprintf(__('Add or remove %s', 'pollora'), $plural), /* translators: %s: taxonomy general name (plural) */ - 'choose_from_most_used' => sprintf(__('Choose from the most used %s', $textDomain), $plural), + 'choose_from_most_used' => sprintf(__('Choose from the most used %s', 'pollora'), $plural), /* translators: %s: taxonomy general name (plural) */ - 'not_found' => sprintf(__('No %s found', $textDomain), $plural), + 'not_found' => sprintf(__('No %s found', 'pollora'), $plural), /* translators: %s: taxonomy singular name */ - 'parent_item' => sprintf(__('Parent %s', $textDomain), $singular), + 'parent_item' => sprintf(__('Parent %s', 'pollora'), $singular), /* translators: %s: taxonomy singular name */ - 'parent_item_colon' => sprintf(__('Parent %s:', $textDomain), $singular), + 'parent_item_colon' => sprintf(__('Parent %s:', 'pollora'), $singular), ]; } } diff --git a/tests/Unit/Translation/DefaultLabelsTest.php b/tests/Unit/Translation/DefaultLabelsTest.php index 7d2cc6e0..f541f3a4 100644 --- a/tests/Unit/Translation/DefaultLabelsTest.php +++ b/tests/Unit/Translation/DefaultLabelsTest.php @@ -21,6 +21,27 @@ use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Spatie\StructureDiscoverer\Data\DiscoveredClass; +/* + * Stand-ins for the two WordPress functions that load a catalogue. They are real functions, not Brain Monkey + * stubs: a stub would leave the function defined for every test that runs after, each of them then failing + * on a call it did not mock. + */ +if (! function_exists('determine_locale')) { + function determine_locale(): string + { + return $GLOBALS['i18nLoader']['locale'] ?? 'en_US'; + } +} + +if (! function_exists('load_textdomain')) { + function load_textdomain(string $domain, string $mofile): bool + { + $GLOBALS['i18nLoader']['loads'][] = $domain.'|'.$mofile; + + return false; + } +} + /** WordPress's stand-in for a domain with no catalogue, which these tests do not load. */ if (! class_exists('NOOP_Translations')) { class NOOP_Translations {} @@ -200,12 +221,15 @@ function i18nLabelsRestrictedTo(array $labels, array $covering): array ->and($labels['edit_item'])->toBe('Edit Book'); }); - it('use the templates of the text domain of the entity', function (): void { + it('take the templates from pollora and the names from the text domain of the entity', function (): void { i18nWiringCatalogue([]); i18nLabelsRegistered(I18nLabelsThemed::class); + $calls = i18nWiringCalls(); - expect(array_unique(array_map(fn (string $call): string => explode('|', $call)[0], i18nWiringCalls())))->toBe(['my-theme']); + expect(array_values(array_filter($calls, fn (string $call): bool => str_starts_with($call, 'my-theme|'))))->toBe(['my-theme|Book', 'my-theme|Books']) + ->and($calls)->toContain('pollora|Add New %s') + ->and($calls)->not->toContain('my-theme|Add New %s'); }); it('are built by the registry itself for the exact arrays the discovery gives', function (): void { @@ -226,7 +250,7 @@ function i18nLabelsRestrictedTo(array $labels, array $covering): array describe('default labels with a catalogue', function (): void { it('translate the names and the templates on init, and not at boot', function (): void { - i18nWiringCatalogue(['my-theme|Book' => 'Livre', 'my-theme|Books' => 'Livres', 'my-theme|Add New %s' => 'Ajouter un nouveau %s', 'my-theme|All %s' => 'Tous les %s']); + i18nWiringCatalogue(['my-theme|Book' => 'Livre', 'my-theme|Books' => 'Livres', 'pollora|Add New %s' => 'Ajouter un nouveau %s', 'pollora|All %s' => 'Tous les %s', 'my-theme|Add New %s' => 'NOT THIS ONE']); $hooks = i18nWiringInitHooks(); $registered = i18nWiringWordPress(); @@ -297,7 +321,7 @@ function i18nLabelsRestrictedTo(array $labels, array $covering): array expect(i18nWiringCalls())->not->toContain('my-theme|Book') ->and(i18nWiringCalls())->not->toContain('my-theme|Books') - ->and(i18nWiringCalls())->toContain('my-theme|Add New %s') + ->and(i18nWiringCalls())->toContain('pollora|Add New %s') ->and($recorder->registered['args']['labels'])->toBe(i18nLabelsBookPostType()) ->and($recorder->registered['names'])->toBe(['singular' => 'Book', 'plural' => 'Books']); }); @@ -324,3 +348,132 @@ function i18nLabelsRestrictedTo(array $labels, array $covering): array ->and($calls)->toHaveCount(2 + 15); }); }); + +/** + * @return array The arguments the discovery hands over for a themed post type with texts written by attributes. + */ +function i18nTextsArguments(): array +{ + return [ + 'text_domain' => 'my-theme', + 'label' => 'Library', + 'description' => 'All the books', + 'title_placeholder' => 'Title here', + 'featured_image' => 'Cover', + 'labels' => [...TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'menu_name' => 'Library', 'add_new' => 'Create'], + 'admin_cols' => ['isbn' => ['title' => 'ISBN', 'meta_key' => 'isbn']], + 'public' => true, + ]; +} + +describe('texts an attribute writes', function (): void { + it('are translated in the text domain of the entity', function (): void { + i18nWiringCatalogue(['my-theme|Library' => 'Bibliotheque', 'my-theme|All the books' => 'Tous les livres', 'my-theme|Title here' => 'Titre ici', 'my-theme|Cover' => 'Couverture', 'my-theme|ISBN' => 'ISBN FR', 'my-theme|Create' => 'Creer']); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', i18nTextsArguments(), ['singular' => 'Book', 'plural' => 'Books']); + $args = $recorder->registered['args']; + + expect($args['label'])->toBe('Bibliotheque') + ->and($args['description'])->toBe('Tous les livres') + ->and($args['title_placeholder'])->toBe('Titre ici') + ->and($args['featured_image'])->toBe('Couverture') + ->and($args['labels']['menu_name'])->toBe('Bibliotheque') + ->and($args['labels']['add_new'])->toBe('Creer') + ->and($args['admin_cols']['isbn'])->toBe(['title' => 'ISBN FR', 'meta_key' => 'isbn']) + ->and($args['public'])->toBeTrue() + ->and(i18nWiringCalls())->not->toContain('pollora|Library'); + }); + + it('come back as they were written when no catalogue has them', function (): void { + i18nWiringCatalogue([]); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', i18nTextsArguments(), ['singular' => 'Book', 'plural' => 'Books']); + $args = $recorder->registered['args']; + + expect($args['label'])->toBe('Library') + ->and($args['labels']['menu_name'])->toBe('Library') + ->and($args['labels']['add_new'])->toBe('Create') + ->and($args['admin_cols']['isbn']['title'])->toBe('ISBN'); + }); + + it('are looked up once each, and neither the default labels nor a resolved Translatable are looked up again', function (): void { + i18nWiringCatalogue(['my-theme|Book' => 'Livre', 'my-theme|Menu' => 'Menu FR', 'my-theme|Menu FR' => 'AGAIN']); + $recorder = i18nWiringRecorder(); + $arguments = i18nTextsArguments(); + $arguments['labels']['all_items'] = new Translatable('Menu'); + + (new TranslatingEntityRegistry($recorder))->register('book', $arguments, ['singular' => 'Book', 'plural' => 'Books']); + $calls = array_count_values(i18nWiringCalls()); + + expect($recorder->registered['args']['labels']['all_items'])->toBe('Menu FR') + ->and($calls['my-theme|Menu'])->toBe(1) + ->and($calls['my-theme|Library'])->toBe(2) + ->and($calls)->not->toHaveKey('my-theme|Menu FR') + ->and($calls)->not->toHaveKey('my-theme|Add New Livre') + ->and($calls)->not->toHaveKey('my-theme|Add New %s'); + }); + + it('are not looked up in a domain with nothing to translate them', function (): void { + i18nWiringCatalogue([]); + Functions\when('get_translations_for_domain')->justReturn(new NOOP_Translations); + Functions\when('has_filter')->justReturn(false); + $recorder = i18nWiringRecorder(); + + (new TranslatingEntityRegistry($recorder))->register('book', i18nTextsArguments(), ['singular' => 'Book', 'plural' => 'Books']); + + expect(array_filter(i18nWiringCalls(), fn (string $call): bool => str_starts_with($call, 'my-theme|')))->toBe([]) + ->and($recorder->registered['args']['label'])->toBe('Library'); + }); + + it('are left alone in an entity that did not ask for default labels', function (): void { + i18nWiringCatalogue(['my-theme|Library' => 'Bibliotheque']); + $recorder = i18nWiringRecorder(); + $arguments = i18nTextsArguments(); + $arguments['labels'] = ['menu_name' => 'Library']; + + (new TranslatingEntityRegistry($recorder))->register('book', $arguments, []); + + expect($recorder->registered['args']['label'])->toBe('Library') + ->and($recorder->registered['args']['labels'])->toBe(['menu_name' => 'Library']) + ->and(i18nWiringCalls())->toBe([]); + }); +}); + +describe('catalogue of the framework', function (): void { + beforeEach(function (): void { + unset($GLOBALS['l10n']['pollora']); + $GLOBALS['i18nLoader'] = ['locale' => 'en_US', 'loads' => []]; + }); + + afterEach(function (): void { + unset($GLOBALS['l10n']['pollora'], $GLOBALS['i18nLoader']); + }); + + it('is loaded, the site translations first, when the default labels are built, and asked once per locale', function (): void { + i18nWiringCatalogue([]); + unset($GLOBALS['l10n']['pollora']); + $GLOBALS['i18nLoader']['locale'] = 'xx_FIRST'; + $registry = new TranslatingEntityRegistry(i18nWiringRecorder()); + $arguments = ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')]; + + $registry->register('book', $arguments, []); + $registry->register('movie', $arguments, []); + + expect($GLOBALS['i18nLoader']['loads'])->toBe([ + 'pollora|'.WP_LANG_DIR.'/pollora/pollora-xx_FIRST.mo', + 'pollora|'.dirname(__DIR__, 3).'/resources/languages/pollora-xx_FIRST.mo', + ]); + }); + + it('is not loaded again when it is', function (): void { + i18nWiringCatalogue([]); + $GLOBALS['i18nLoader']['locale'] = 'xx_SECOND'; + $GLOBALS['l10n']['pollora'] = new stdClass; + + (new TranslatingEntityRegistry(i18nWiringRecorder()))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')], []); + + expect($GLOBALS['i18nLoader']['loads'])->toBe([]); + }); +}); diff --git a/tests/Unit/Translation/TranslatedAttributeTextsTest.php b/tests/Unit/Translation/TranslatedAttributeTextsTest.php new file mode 100644 index 00000000..74bbad13 --- /dev/null +++ b/tests/Unit/Translation/TranslatedAttributeTextsTest.php @@ -0,0 +1,350 @@ +>|null + */ +function i18nDriftDeclaration(): ?array +{ + $installed = dirname((new ReflectionClass(PostType::class))->getFileName(), 2).'/resources/i18n.php'; + + if (is_file($installed)) { + return require $installed; + } + + $framework = dirname(__DIR__, 3); + $candidates = array_filter([getenv('POLLORA_ATTRIBUTES_PATH') ?: null, $framework.'/../attributes', $framework.'/../../../attributes']); + + foreach ($candidates as $folder) { + if (! is_dir($folder.'/.git') && ! is_file($folder.'/.git')) { + continue; + } + + $source = shell_exec('git -C '.escapeshellarg($folder).' show origin/main:resources/i18n.php 2>/dev/null'); + + if (is_string($source) && str_starts_with($source, 'getConstructor()?->getParameters() ?? [] as $parameter) { + if ($parameter->getName() === $field) { + $arguments[$field] = $sentinel; + } elseif (! $parameter->isOptional()) { + $arguments[$parameter->getName()] = 'neutral'; + } + } + + return new $class(...$arguments); +} + +/** + * The registration arguments an attribute writes, on top of the default labels the discovery asks for. + * + * @return array + */ +function i18nDriftArguments(object $attribute, string $kind): array +{ + $arguments = ['labels' => TranslatingEntityRegistry::defaultLabels($kind, 'Name', 'Names'), 'text_domain' => 'drift-domain']; + + if ($attribute instanceof ConfiguresArgumentsWithMethod) { + return $attribute->configureArguments($arguments, fn (): null => null); + } + + if ($attribute instanceof ConfiguresArguments) { + return $attribute->configureArguments($arguments); + } + + return $arguments; +} + +/** + * Where a string sits in nested arguments, as a list of keys; null when it is nowhere. + * + * @param array $arguments + * @return list|null + */ +function i18nDriftPath(array $arguments, string $needle): ?array +{ + foreach ($arguments as $key => $value) { + if ($value === $needle) { + return [$key]; + } + + if (is_array($value) && ($inner = i18nDriftPath($value, $needle)) !== null) { + return [$key, ...$inner]; + } + } + + return null; +} + +/** + * @param array $arguments + * @param list $path + */ +function i18nDriftValue(array $arguments, array $path): mixed +{ + foreach ($path as $key) { + $arguments = $arguments[$key]; + } + + return $arguments; +} + +/** + * Registers the arguments with a catalogue that turns every `sentinel` into `T:sentinel` in the domain of the entity. + * + * @param array $arguments + * @return array What WordPress was given + */ +function i18nDriftWordPress(): void +{ + defined('WP_LANG_DIR') || define('WP_LANG_DIR', '/tmp/wp-lang'); + Functions\when('get_locale')->justReturn('fr_FR'); + $GLOBALS['l10n']['pollora'] = new stdClass; + Functions\when('get_translations_for_domain')->justReturn(new stdClass); + Functions\when('has_filter')->justReturn(false); + Functions\when('translate')->alias(fn (string $text, string $domain = 'default'): string => $domain === 'drift-domain' && in_array($text, $GLOBALS['i18nDrift'], true) ? 'T:'.$text : $text); + Functions\when('_x')->alias(fn (string $text): string => $text); + + // Without WordPress loaded, __() is Laravel's: the text domain arrives where the replacements would. + Container::getInstance()->instance('translator', new class + { + public function get(string $key, mixed $replace = [], ?string $locale = null): string + { + $domain = is_string($replace) ? $replace : 'default'; + + return $domain === 'drift-domain' && in_array($key, $GLOBALS['i18nDrift'], true) ? 'T:'.$key : $key; + } + }); +} + +function i18nDriftRegister(array $arguments, string $sentinel): array +{ + i18nDriftWordPress(); + $GLOBALS['i18nDrift'] = [$sentinel]; + + $recorder = new class implements EntityRegistryPort + { + /** @var array */ + public array $args = []; + + public function register(string $slug, array $args, array $names): void + { + $this->args = $args; + } + }; + + (new TranslatingEntityRegistry($recorder))->register('drift', $arguments, ['singular' => 'Name', 'plural' => 'Names']); + + return $recorder->args; +} + +/** + * The fields of an attribute whose value is text, with the kind of registration that takes the attribute. + * + * @param class-string $class + * @return list + */ +function i18nDriftStringFields(string $class): array +{ + $fields = []; + + foreach ((new ReflectionClass($class))->getConstructor()?->getParameters() ?? [] as $parameter) { + $type = (string) $parameter->getType(); + + if (in_array(ltrim($type, '?'), ['string', 'bool|string', 'string|bool'], true)) { + $fields[] = $parameter->getName(); + } + } + + return $fields; +} + +it('declares only attributes this test knows to be, or not to be, registration arguments', function (): void { + $declaration = i18nDriftDeclaration() ?? $this->markTestSkipped('pollora/attributes does not ship resources/i18n.php here and no checkout of it was found.'); + + $unknown = array_diff(array_keys($declaration), I18N_DRIFT_REGISTRATION, I18N_DRIFT_ELSEWHERE); + + expect($unknown)->toBe([], 'A new attribute declares translatable fields: say here whether the registration of a post type or a taxonomy takes them.') + ->and(array_diff(I18N_DRIFT_REGISTRATION, array_keys($declaration)))->toBe([]); +}); + +it('translates on registration every declared field of the names of a post type and of a taxonomy, and only those', function (string $class, string $kind): void { + $declaration = i18nDriftDeclaration() ?? $this->markTestSkipped('pollora/attributes does not ship resources/i18n.php here and no checkout of it was found.'); + + expect($declaration[$class])->toBe(['singular', 'plural']); + + $recorder = new class implements EntityRegistryPort + { + /** @var array{args: array, names: array} */ + public array $registered = ['args' => [], 'names' => []]; + + public function register(string $slug, array $args, array $names): void + { + $this->registered = ['args' => $args, 'names' => $names]; + } + }; + i18nDriftWordPress(); + $GLOBALS['i18nDrift'] = ['S', 'P']; + + (new TranslatingEntityRegistry($recorder))->register('drift', ['labels' => TranslatingEntityRegistry::defaultLabels($kind, 'S', 'P'), 'text_domain' => 'drift-domain'], ['singular' => 'S', 'plural' => 'P']); + + expect($recorder->registered['names'])->toBe(['singular' => 'T:S', 'plural' => 'T:P']) + ->and($recorder->registered['args']['labels']['singular_name'])->toBe('T:S') + ->and($recorder->registered['args']['labels']['name'])->toBe('T:P'); +})->with([ + 'post type' => [PostType::class, 'post_type'], + 'taxonomy' => [Taxonomy::class, 'taxonomy'], +]); + +it('translates on registration every declared field of an attribute that writes registration arguments', function (string $class, string $kind): void { + $declaration = i18nDriftDeclaration() ?? $this->markTestSkipped('pollora/attributes does not ship resources/i18n.php here and no checkout of it was found.'); + + expect($declaration[$class])->not->toBeEmpty(); + + foreach ($declaration[$class] as $field) { + $sentinel = 'sentinel-'.$field; + $arguments = i18nDriftArguments(i18nDriftAttribute($class, $field, $sentinel), $kind); + $path = i18nDriftPath($arguments, $sentinel); + + expect($path)->not->toBeNull("{$class}::\${$field} is declared translatable and reaches no registration argument."); + + $registered = i18nDriftRegister($arguments, $sentinel); + + expect(i18nDriftValue($registered, $path))->toBe('T:'.$sentinel, "{$class}::\${$field} is declared translatable and is not translated on registration (argument ".implode('.', $path).').'); + } +})->with([ + 'post type labels' => [PostType\Labels::class, 'post_type'], + 'post type label' => [PostType\Label::class, 'post_type'], + 'post type description' => [PostType\Description::class, 'post_type'], + 'post type title placeholder' => [PostType\TitlePlaceholder::class, 'post_type'], + 'post type featured image' => [PostType\FeaturedImage::class, 'post_type'], + 'post type admin column' => [PostType\AdminCol::class, 'post_type'], + 'taxonomy labels' => [Taxonomy\Labels::class, 'taxonomy'], + 'taxonomy label' => [Taxonomy\Label::class, 'taxonomy'], + 'taxonomy description' => [Taxonomy\Description::class, 'taxonomy'], +]); + +it('translates nothing an attribute leaves out of its declaration', function (string $class, string $kind): void { + $declaration = i18nDriftDeclaration() ?? $this->markTestSkipped('pollora/attributes does not ship resources/i18n.php here and no checkout of it was found.'); + + $translated = []; + + foreach (array_diff(i18nDriftStringFields($class), $declaration[$class]) as $field) { + $sentinel = 'sentinel-'.$field; + $arguments = i18nDriftArguments(i18nDriftAttribute($class, $field, $sentinel), $kind); + $path = i18nDriftPath($arguments, $sentinel); + + if ($path === null) { + continue; + } + + $registered = i18nDriftRegister($arguments, $sentinel); + + if (i18nDriftValue($registered, $path) !== $sentinel) { + $translated[] = "{$class}::\${$field} (argument ".implode('.', $path).')'; + } + } + + expect($translated)->toBe([], 'Translated on registration, yet not declared translatable.'); +})->with([ + 'post type labels' => [PostType\Labels::class, 'post_type'], + 'post type label' => [PostType\Label::class, 'post_type'], + 'post type description' => [PostType\Description::class, 'post_type'], + 'post type title placeholder' => [PostType\TitlePlaceholder::class, 'post_type'], + 'post type featured image' => [PostType\FeaturedImage::class, 'post_type'], + 'post type admin column' => [PostType\AdminCol::class, 'post_type'], + 'taxonomy labels' => [Taxonomy\Labels::class, 'taxonomy'], + 'taxonomy label' => [Taxonomy\Label::class, 'taxonomy'], + 'taxonomy description' => [Taxonomy\Description::class, 'taxonomy'], +]); + +it('lists as translated arguments exactly the ones a declared field of a single-text attribute writes', function (): void { + $declaration = i18nDriftDeclaration() ?? $this->markTestSkipped('pollora/attributes does not ship resources/i18n.php here and no checkout of it was found.'); + $written = []; + + foreach ([PostType\Label::class, PostType\Description::class, PostType\TitlePlaceholder::class, PostType\FeaturedImage::class, Taxonomy\Label::class, Taxonomy\Description::class] as $class) { + foreach ($declaration[$class] as $field) { + $arguments = i18nDriftArguments(i18nDriftAttribute($class, $field, 'sentinel'), 'post_type'); + $written[] = i18nDriftPath($arguments, 'sentinel')[0]; + } + } + + $written = array_values(array_unique($written)); + $translated = TranslatingEntityRegistry::TRANSLATED_ARGUMENTS; + sort($written); + sort($translated); + + expect($translated)->toBe($written); +}); diff --git a/tests/Unit/Translation/TranslatingEntityRegistryTest.php b/tests/Unit/Translation/TranslatingEntityRegistryTest.php index d63a42dc..9884ff5f 100644 --- a/tests/Unit/Translation/TranslatingEntityRegistryTest.php +++ b/tests/Unit/Translation/TranslatingEntityRegistryTest.php @@ -43,6 +43,9 @@ function i18nWiringCatalogue(array $words): void }; Functions\when('get_locale')->justReturn('fr_FR'); + defined('WP_LANG_DIR') || define('WP_LANG_DIR', '/tmp/wp-lang'); + // The catalogue of the framework counts as loaded: the registry does not try to load it. + $GLOBALS['l10n']['pollora'] = new stdClass; Functions\when('get_translations_for_domain')->justReturn(new stdClass); Functions\when('has_filter')->justReturn(false); Functions\when('translate')->alias(fn (string $text, string $domain = 'default'): string => $lookup($domain.'|'.$text, $text)); From 0d0a6fdec8f1a135fd755abea0a3537c4c238d85 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 12 Nov 2026 09:01:36 +0100 Subject: [PATCH 04/10] feat(translation): add the default label templates missing from the pollora catalogue The templates of the default labels of post types and taxonomies are looked up in the pollora domain. Five were in no catalogue: "No %s found in Trash", "%s Archives", "%s Attributes", "Insert into %s" and "Uploaded to this %s". They are added to the template and, untranslated, to each language file; the references of the other templates now point at their current place. --- resources/languages/pollora-de_DE.po | 22 ++++++++- resources/languages/pollora-es_ES.po | 22 ++++++++- resources/languages/pollora-fr_FR.po | 20 ++++++++ resources/languages/pollora-it_IT.po | 22 ++++++++- resources/languages/pollora-ja.po | 22 ++++++++- resources/languages/pollora-nl_NL.po | 22 ++++++++- resources/languages/pollora-pt_BR.po | 22 ++++++++- resources/languages/pollora.pot | 70 ++++++++++++++++++++++------ 8 files changed, 202 insertions(+), 20 deletions(-) diff --git a/resources/languages/pollora-de_DE.po b/resources/languages/pollora-de_DE.po index 6a810648..ac0531ec 100644 --- a/resources/languages/pollora-de_DE.po +++ b/resources/languages/pollora-de_DE.po @@ -238,4 +238,24 @@ msgid "You are running Pollora %1$s. The latest version is %2$s. %3$sView change msgstr "Du verwendest Pollora %1$s. Die neueste Version ist %2$s. %3$sChangelog ansehen%4$s." msgid "Learn more about updating Pollora" -msgstr "Mehr über die Aktualisierung von Pollora erfahren" \ No newline at end of file +msgstr "Mehr über die Aktualisierung von Pollora erfahren" + +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#, php-format +msgid "%s Archives" +msgstr "" + +#, php-format +msgid "%s Attributes" +msgstr "" + +#, php-format +msgid "Insert into %s" +msgstr "" + +#, php-format +msgid "Uploaded to this %s" +msgstr "" diff --git a/resources/languages/pollora-es_ES.po b/resources/languages/pollora-es_ES.po index 3a64779d..3f720b2a 100644 --- a/resources/languages/pollora-es_ES.po +++ b/resources/languages/pollora-es_ES.po @@ -238,4 +238,24 @@ msgid "You are running Pollora %1$s. The latest version is %2$s. %3$sView change msgstr "Estás usando Pollora %1$s. La última versión es %2$s. %3$sVer changelog%4$s." msgid "Learn more about updating Pollora" -msgstr "Más información sobre la actualización de Pollora" \ No newline at end of file +msgstr "Más información sobre la actualización de Pollora" + +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#, php-format +msgid "%s Archives" +msgstr "" + +#, php-format +msgid "%s Attributes" +msgstr "" + +#, php-format +msgid "Insert into %s" +msgstr "" + +#, php-format +msgid "Uploaded to this %s" +msgstr "" diff --git a/resources/languages/pollora-fr_FR.po b/resources/languages/pollora-fr_FR.po index b03f8425..3208d8dc 100644 --- a/resources/languages/pollora-fr_FR.po +++ b/resources/languages/pollora-fr_FR.po @@ -241,3 +241,23 @@ msgid "Learn more about updating Pollora" msgstr "En savoir plus sur la mise à jour de Pollora" msgid "Powered by Pollora" msgstr "Propulsé par Pollora" + +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#, php-format +msgid "%s Archives" +msgstr "" + +#, php-format +msgid "%s Attributes" +msgstr "" + +#, php-format +msgid "Insert into %s" +msgstr "" + +#, php-format +msgid "Uploaded to this %s" +msgstr "" diff --git a/resources/languages/pollora-it_IT.po b/resources/languages/pollora-it_IT.po index 84c77a14..cf79cb57 100644 --- a/resources/languages/pollora-it_IT.po +++ b/resources/languages/pollora-it_IT.po @@ -238,4 +238,24 @@ msgid "You are running Pollora %1$s. The latest version is %2$s. %3$sView change msgstr "Stai usando Pollora %1$s. L'ultima versione è %2$s. %3$sVedi changelog%4$s." msgid "Learn more about updating Pollora" -msgstr "Scopri di più sull'aggiornamento di Pollora" \ No newline at end of file +msgstr "Scopri di più sull'aggiornamento di Pollora" + +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#, php-format +msgid "%s Archives" +msgstr "" + +#, php-format +msgid "%s Attributes" +msgstr "" + +#, php-format +msgid "Insert into %s" +msgstr "" + +#, php-format +msgid "Uploaded to this %s" +msgstr "" diff --git a/resources/languages/pollora-ja.po b/resources/languages/pollora-ja.po index ec900907..0b183651 100644 --- a/resources/languages/pollora-ja.po +++ b/resources/languages/pollora-ja.po @@ -238,4 +238,24 @@ msgid "You are running Pollora %1$s. The latest version is %2$s. %3$sView change msgstr "Pollora %1$sを使用しています。最新バージョンは%2$sです。%3$s変更履歴を見る%4$s。" msgid "Learn more about updating Pollora" -msgstr "Polloraの更新について詳しく見る" \ No newline at end of file +msgstr "Polloraの更新について詳しく見る" + +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#, php-format +msgid "%s Archives" +msgstr "" + +#, php-format +msgid "%s Attributes" +msgstr "" + +#, php-format +msgid "Insert into %s" +msgstr "" + +#, php-format +msgid "Uploaded to this %s" +msgstr "" diff --git a/resources/languages/pollora-nl_NL.po b/resources/languages/pollora-nl_NL.po index a8043cc2..ca63b2e5 100644 --- a/resources/languages/pollora-nl_NL.po +++ b/resources/languages/pollora-nl_NL.po @@ -238,4 +238,24 @@ msgid "You are running Pollora %1$s. The latest version is %2$s. %3$sView change msgstr "Je gebruikt Pollora %1$s. De nieuwste versie is %2$s. %3$sChangelog bekijken%4$s." msgid "Learn more about updating Pollora" -msgstr "Meer informatie over het bijwerken van Pollora" \ No newline at end of file +msgstr "Meer informatie over het bijwerken van Pollora" + +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#, php-format +msgid "%s Archives" +msgstr "" + +#, php-format +msgid "%s Attributes" +msgstr "" + +#, php-format +msgid "Insert into %s" +msgstr "" + +#, php-format +msgid "Uploaded to this %s" +msgstr "" diff --git a/resources/languages/pollora-pt_BR.po b/resources/languages/pollora-pt_BR.po index c47f4290..feeeed4a 100644 --- a/resources/languages/pollora-pt_BR.po +++ b/resources/languages/pollora-pt_BR.po @@ -238,4 +238,24 @@ msgid "You are running Pollora %1$s. The latest version is %2$s. %3$sView change msgstr "Você está usando o Pollora %1$s. A versão mais recente é %2$s. %3$sVer changelog%4$s." msgid "Learn more about updating Pollora" -msgstr "Saiba mais sobre a atualização do Pollora" \ No newline at end of file +msgstr "Saiba mais sobre a atualização do Pollora" + +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#, php-format +msgid "%s Archives" +msgstr "" + +#, php-format +msgid "%s Attributes" +msgstr "" + +#, php-format +msgid "Insert into %s" +msgstr "" + +#, php-format +msgid "Uploaded to this %s" +msgstr "" diff --git a/resources/languages/pollora.pot b/resources/languages/pollora.pot index 199cc0b6..101ad37c 100644 --- a/resources/languages/pollora.pot +++ b/resources/languages/pollora.pot @@ -182,21 +182,25 @@ msgstr "" #. translators: %s: post type general name (plural) #. translators: %s: taxonomy general name (plural) #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:106 -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:126 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:106 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:256 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:285 #, php-format msgid "All %s" msgstr "" +#. translators: %s: post type general name (plural) #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:107 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:236 msgid "Add New" msgstr "" #. translators: %s: post type singular name #. translators: %s: taxonomy singular name #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:109 -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:134 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:110 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:238 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:293 #, php-format msgid "Add New %s" msgstr "" @@ -204,23 +208,28 @@ msgstr "" #. translators: %s: post type singular name #. translators: %s: taxonomy singular name #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:111 -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:128 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:107 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:240 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:287 #, php-format msgid "Edit %s" msgstr "" #. translators: %s: post type singular name #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:113 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:242 #, php-format msgid "New %s" msgstr "" #. translators: %s: post type singular name +#. translators: %s: post type general name (plural) #. translators: %s: taxonomy singular name #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:115 -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:130 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:108 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:244 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:246 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:289 #, php-format msgid "View %s" msgstr "" @@ -228,8 +237,9 @@ msgstr "" #. translators: %s: post type general name (plural) #. translators: %s: taxonomy general name (plural) #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:117 -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:138 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:112 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:248 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:297 #, php-format msgid "Search %s" msgstr "" @@ -237,8 +247,9 @@ msgstr "" #. translators: %s: post type general name (plural) #. translators: %s: taxonomy general name (plural) #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:119 -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:148 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:117 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:250 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:307 #, php-format msgid "No %s found" msgstr "" @@ -252,57 +263,58 @@ msgstr "" #. translators: %s: post type singular name #. translators: %s: taxonomy singular name #: PostType/Infrastructure/Adapters/WordPressPostTypeRegistry.php:123 -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:152 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:119 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:254 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:311 #, php-format msgid "Parent %s:" msgstr "" #. translators: %s: taxonomy singular name -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:132 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:109 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:291 #, php-format msgid "Update %s" msgstr "" #. translators: %s: taxonomy singular name -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:136 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:111 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:295 #, php-format msgid "New %s Name" msgstr "" #. translators: %s: taxonomy general name (plural) -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:140 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:113 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:299 #, php-format msgid "Popular %s" msgstr "" #. translators: %s: taxonomy general name (plural) -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:142 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:114 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:301 #, php-format msgid "Separate %s with commas" msgstr "" #. translators: %s: taxonomy general name (plural) -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:144 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:115 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:303 #, php-format msgid "Add or remove %s" msgstr "" #. translators: %s: taxonomy general name (plural) -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:146 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:116 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:305 #, php-format msgid "Choose from the most used %s" msgstr "" #. translators: %s: taxonomy singular name -#: Taxonomy/Domain/Models/AbstractTaxonomy.php:150 #: Taxonomy/Infrastructure/Adapters/WordPressTaxonomyRegistry.php:118 +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:309 #, php-format msgid "Parent %s" msgstr "" @@ -366,3 +378,33 @@ msgstr "" #: Login/Infrastructure/Services/LoginScreen.php:125 msgid "Powered by Pollora" msgstr "" + +#. translators: %s: post type general name (plural) +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:252 +#, php-format +msgid "No %s found in Trash" +msgstr "" + +#. translators: %s: post type singular name +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:258 +#, php-format +msgid "%s Archives" +msgstr "" + +#. translators: %s: post type singular name +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:260 +#, php-format +msgid "%s Attributes" +msgstr "" + +#. translators: %s: post type singular name +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:262 +#, php-format +msgid "Insert into %s" +msgstr "" + +#. translators: %s: post type singular name +#: Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php:264 +#, php-format +msgid "Uploaded to this %s" +msgstr "" From 38a88e44f8d12f362e293af0f582babecd545a9c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 12 Nov 2026 15:55:40 +0100 Subject: [PATCH 05/10] refactor(translation): always look the names and the texts of attributes up through __() The check that skipped the lookup when a domain had no catalogue and nothing was on the `gettext` filter cost about as much as the lookup it saved, and it left out the `gettext_{domain}` and `gettext_with_context_{domain}` filters. The singular and plural names, the explicit labels, `label`, `description`, `title_placeholder`, `featured_image` and the titles of the admin columns now all go through __(). The catalogue of the framework is still loaded on init. --- .../Adapters/TranslatingEntityRegistry.php | 28 ++--------- tests/Unit/Translation/DefaultLabelsTest.php | 49 ++++++++++--------- .../TranslatedAttributeTextsTest.php | 2 - .../TranslatingEntityRegistryTest.php | 24 +++++++-- 4 files changed, 49 insertions(+), 54 deletions(-) diff --git a/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php index 8c36966c..4073f2f6 100644 --- a/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php +++ b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php @@ -30,7 +30,8 @@ * entity that did not ask for default labels is registered with the arguments it has, resolved. * * A text with no translation comes back as it was, so a label written directly in the - * language of the site stays. Nothing is looked up when nothing could translate it. + * language of the site stays. Every text goes through __(), even when no catalogue is + * loaded for its domain, so that the `gettext` filters (and `gettext_{domain}`) still apply. */ final readonly class TranslatingEntityRegistry implements EntityRegistryPort { @@ -135,13 +136,9 @@ private function withTranslatedTexts(array $args, array $names, string $domain): [$kind, $rawSingular, $rawPlural] = $labels[self::DEFAULT_LABELS]; unset($labels[self::DEFAULT_LABELS]); - $translating = ! $this->hasNothingToLookUp($domain); - $singular = $translating ? $this->translateText($rawSingular, $domain) : $rawSingular; - $plural = $translating ? $this->translateText($rawPlural, $domain) : $rawPlural; - - if ($translating) { - [$args, $labels] = $this->withTranslatedExplicitTexts($args, $labels, $domain); - } + $singular = $this->translateText($rawSingular, $domain); + $plural = $this->translateText($rawPlural, $domain); + [$args, $labels] = $this->withTranslatedExplicitTexts($args, $labels, $domain); $args['labels'] = array_merge( $kind === self::TAXONOMY @@ -194,21 +191,6 @@ private function withTranslatedExplicitTexts(array $args, array $labels, string return [$args, $labels]; } - /** - * Whether __() would give the text back: no catalogue for the domain and nothing on the `gettext` filter. - * - * Reads what WordPress keeps in memory. A domain with no catalogue is a NOOP_Translations - * (the site in its source language, or a theme with no file for the locale), and stays one - * after the first lookup, where is_textdomain_loaded() would say true. A plugin that - * translates through the `gettext` filter still gets its lookups. - */ - private function hasNothingToLookUp(string $domain): bool - { - return function_exists('get_translations_for_domain') - && is_a(get_translations_for_domain($domain), 'NOOP_Translations') - && ! has_filter('gettext'); - } - /** * A text written in the source language, translated in the given text domain. */ diff --git a/tests/Unit/Translation/DefaultLabelsTest.php b/tests/Unit/Translation/DefaultLabelsTest.php index f541f3a4..75875856 100644 --- a/tests/Unit/Translation/DefaultLabelsTest.php +++ b/tests/Unit/Translation/DefaultLabelsTest.php @@ -42,11 +42,6 @@ function load_textdomain(string $domain, string $mofile): bool } } -/** WordPress's stand-in for a domain with no catalogue, which these tests do not load. */ -if (! class_exists('NOOP_Translations')) { - class NOOP_Translations {} -} - /* * The default labels of a post type or a taxonomy are built on init, from the singular and * plural names translated in the text domain of the entity. Without a catalogue they are @@ -310,30 +305,30 @@ function i18nLabelsRestrictedTo(array $labels, array $covering): array expect($recorder->registered['args']['labels']['menu_name'])->toBe('Bibliotheque'); }); - it('do not look the names up in a domain with no catalogue, and still ask for the templates', function (): void { + it('still ask for the names with no catalogue, so that the gettext filters apply to them', function (): void { i18nWiringCatalogue([]); - Functions\when('get_translations_for_domain')->justReturn(new NOOP_Translations); - Functions\when('has_filter')->justReturn(false); - + $GLOBALS['i18nWiring']['filters'] = [ + 'gettext' => fn (string $translation, string $text): string => $text === 'Book' ? 'Livre' : $translation, + 'gettext_my-theme' => fn (string $translation, string $text): string => $text === 'Books' ? 'Livres' : $translation, + ]; $recorder = i18nWiringRecorder(); (new TranslatingEntityRegistry($recorder))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'text_domain' => 'my-theme'], ['singular' => 'Book', 'plural' => 'Books']); - expect(i18nWiringCalls())->not->toContain('my-theme|Book') - ->and(i18nWiringCalls())->not->toContain('my-theme|Books') - ->and(i18nWiringCalls())->toContain('pollora|Add New %s') - ->and($recorder->registered['args']['labels'])->toBe(i18nLabelsBookPostType()) - ->and($recorder->registered['names'])->toBe(['singular' => 'Book', 'plural' => 'Books']); + expect($recorder->registered['names'])->toBe(['singular' => 'Livre', 'plural' => 'Livres']) + ->and($recorder->registered['args']['labels']['singular_name'])->toBe('Livre') + ->and($recorder->registered['args']['labels']['add_new_item'])->toBe('Add New Livre'); }); - it('look the names up in a domain with no catalogue when a plugin translates through the gettext filter', function (): void { + it('give the names back unchanged with no catalogue and no filter, after asking for them', function (): void { i18nWiringCatalogue([]); - Functions\when('get_translations_for_domain')->justReturn(new NOOP_Translations); - Functions\when('has_filter')->justReturn(10); + $recorder = i18nWiringRecorder(); - (new TranslatingEntityRegistry(i18nWiringRecorder()))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'text_domain' => 'my-theme'], ['singular' => 'Book', 'plural' => 'Books']); + (new TranslatingEntityRegistry($recorder))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books'), 'text_domain' => 'my-theme'], ['singular' => 'Book', 'plural' => 'Books']); - expect(i18nWiringCalls())->toContain('my-theme|Book')->toContain('my-theme|Books'); + expect(i18nWiringCalls())->toContain('my-theme|Book')->toContain('my-theme|Books')->toContain('pollora|Add New %s') + ->and($recorder->registered['args']['labels'])->toBe(i18nLabelsBookPostType()) + ->and($recorder->registered['names'])->toBe(['singular' => 'Book', 'plural' => 'Books']); }); it('look each name up once, and no template more than the discovery did', function (): void { @@ -415,16 +410,22 @@ function i18nTextsArguments(): array ->and($calls)->not->toHaveKey('my-theme|Add New %s'); }); - it('are not looked up in a domain with nothing to translate them', function (): void { + it('are still looked up with no catalogue, so that the gettext filters of the site and of the domain apply', function (): void { i18nWiringCatalogue([]); - Functions\when('get_translations_for_domain')->justReturn(new NOOP_Translations); - Functions\when('has_filter')->justReturn(false); + $GLOBALS['i18nWiring']['filters'] = [ + 'gettext' => fn (string $translation, string $text): string => $text === 'Library' ? 'Bibliotheque' : $translation, + 'gettext_my-theme' => fn (string $translation, string $text): string => $text === 'Cover' ? 'Couverture' : ($text === 'ISBN' ? 'ISBN FR' : $translation), + ]; $recorder = i18nWiringRecorder(); (new TranslatingEntityRegistry($recorder))->register('book', i18nTextsArguments(), ['singular' => 'Book', 'plural' => 'Books']); + $args = $recorder->registered['args']; - expect(array_filter(i18nWiringCalls(), fn (string $call): bool => str_starts_with($call, 'my-theme|')))->toBe([]) - ->and($recorder->registered['args']['label'])->toBe('Library'); + expect($args['label'])->toBe('Bibliotheque') + ->and($args['labels']['menu_name'])->toBe('Bibliotheque') + ->and($args['featured_image'])->toBe('Couverture') + ->and($args['admin_cols']['isbn']['title'])->toBe('ISBN FR') + ->and($args['description'])->toBe('All the books'); }); it('are left alone in an entity that did not ask for default labels', function (): void { diff --git a/tests/Unit/Translation/TranslatedAttributeTextsTest.php b/tests/Unit/Translation/TranslatedAttributeTextsTest.php index 74bbad13..75c40c23 100644 --- a/tests/Unit/Translation/TranslatedAttributeTextsTest.php +++ b/tests/Unit/Translation/TranslatedAttributeTextsTest.php @@ -172,8 +172,6 @@ function i18nDriftWordPress(): void defined('WP_LANG_DIR') || define('WP_LANG_DIR', '/tmp/wp-lang'); Functions\when('get_locale')->justReturn('fr_FR'); $GLOBALS['l10n']['pollora'] = new stdClass; - Functions\when('get_translations_for_domain')->justReturn(new stdClass); - Functions\when('has_filter')->justReturn(false); Functions\when('translate')->alias(fn (string $text, string $domain = 'default'): string => $domain === 'drift-domain' && in_array($text, $GLOBALS['i18nDrift'], true) ? 'T:'.$text : $text); Functions\when('_x')->alias(fn (string $text): string => $text); diff --git a/tests/Unit/Translation/TranslatingEntityRegistryTest.php b/tests/Unit/Translation/TranslatingEntityRegistryTest.php index 9884ff5f..095b0238 100644 --- a/tests/Unit/Translation/TranslatingEntityRegistryTest.php +++ b/tests/Unit/Translation/TranslatingEntityRegistryTest.php @@ -34,20 +34,18 @@ */ function i18nWiringCatalogue(array $words): void { - $GLOBALS['i18nWiring'] = ['calls' => [], 'words' => $words]; + $GLOBALS['i18nWiring'] = ['calls' => [], 'words' => $words, 'filters' => []]; $lookup = function (string $key, string $text) { $GLOBALS['i18nWiring']['calls'][] = $key; - return $GLOBALS['i18nWiring']['words'][$key] ?? $text; + return i18nWiringFiltered($GLOBALS['i18nWiring']['words'][$key] ?? $text, $text, explode('|', $key)[0]); }; Functions\when('get_locale')->justReturn('fr_FR'); defined('WP_LANG_DIR') || define('WP_LANG_DIR', '/tmp/wp-lang'); // The catalogue of the framework counts as loaded: the registry does not try to load it. $GLOBALS['l10n']['pollora'] = new stdClass; - Functions\when('get_translations_for_domain')->justReturn(new stdClass); - Functions\when('has_filter')->justReturn(false); Functions\when('translate')->alias(fn (string $text, string $domain = 'default'): string => $lookup($domain.'|'.$text, $text)); Functions\when('_x')->alias(fn (string $text, string $context, string $domain = 'default'): string => $lookup($domain.'|'.$context.'|'.$text, $text)); Container::getInstance()->instance('translator', new class @@ -57,11 +55,27 @@ public function get(string $key, mixed $replace = [], ?string $locale = null): s $domain = is_string($replace) ? $replace : 'default'; $GLOBALS['i18nWiring']['calls'][] = $domain.'|'.$key; - return $GLOBALS['i18nWiring']['words'][$domain.'|'.$key] ?? $key; + return i18nWiringFiltered($GLOBALS['i18nWiring']['words'][$domain.'|'.$key] ?? $key, $key, $domain); } }); } +/** + * What WordPress does after the catalogue answered: the `gettext` filters, then the `gettext_{domain}` ones. + * + * @param array $filters Added by a test: 'gettext' or 'gettext_my-theme' => fn (string $translation, string $text, string $domain): string + */ +function i18nWiringFiltered(string $translation, string $text, string $domain): string +{ + foreach (['gettext', 'gettext_'.$domain] as $hook) { + if (isset($GLOBALS['i18nWiring']['filters'][$hook])) { + $translation = $GLOBALS['i18nWiring']['filters'][$hook]($translation, $text, $domain); + } + } + + return $translation; +} + /** * @return list What was asked of the catalogues since i18nWiringCatalogue() */ From 09cd46c3dab2fffefb25e096eb9556f2ceb0f966 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 12 Nov 2026 16:14:43 +0100 Subject: [PATCH 06/10] feat(translation): translate the five default label templates that had no translation No %s found in Trash, %s Archives, %s Attributes, Insert into %s and Uploaded to this %s, in the seven languages the framework ships. Gender-neutral forms, as the other templates of the catalogue. --- resources/languages/pollora-de_DE.mo | Bin 4585 -> 4854 bytes resources/languages/pollora-de_DE.po | 10 +++++----- resources/languages/pollora-es_ES.mo | Bin 4658 -> 4930 bytes resources/languages/pollora-es_ES.po | 10 +++++----- resources/languages/pollora-fr_FR.mo | Bin 4710 -> 4997 bytes resources/languages/pollora-fr_FR.po | 10 +++++----- resources/languages/pollora-it_IT.mo | Bin 4567 -> 4838 bytes resources/languages/pollora-it_IT.po | 10 +++++----- resources/languages/pollora-ja.mo | Bin 5021 -> 5319 bytes resources/languages/pollora-ja.po | 10 +++++----- resources/languages/pollora-nl_NL.mo | Bin 4516 -> 4787 bytes resources/languages/pollora-nl_NL.po | 10 +++++----- resources/languages/pollora-pt_BR.mo | Bin 4645 -> 4916 bytes resources/languages/pollora-pt_BR.po | 10 +++++----- 14 files changed, 35 insertions(+), 35 deletions(-) diff --git a/resources/languages/pollora-de_DE.mo b/resources/languages/pollora-de_DE.mo index 65591eb959fb5593ce72b3844301b8b2e5831a2c..c1a48d425d9e5d7debdd2135c916b70b28d40ad0 100644 GIT binary patch delta 1806 zcmZY9TS!zv9LMo-Q`ft>R$7*wvNgMG<)u=~O0$azQK3;mr0J#$wuZai^kH8U%0LT@ zB7882q=J^{rC#c(h{Bg{L{SiC6eNTXWzqL%69f(CKc6|b|IC@0-O;>pZ{&?9_Pn9g z60?c0%NRdCj-{eB#2b^0jhKpu@eFpM8xyHgFavd-OO=HAI28+#M@<#Z#2S0Q33)GK zf;7}|7iM5T?!#-CfN@mQFb&f&52s@>Dsc_+sM%rNgBoyyJ#WJ}&cm37ov41g(L?{{ zqP=kg=W*j6D&bp9#1YhFjG;OlM|JQMnJkmYLpo1E4K&;8Lk+YFb8!!9MvmhYJcTa$ zH>YWEn6p%xx(n7p)Ie@x7T!T6d}{B1uzp3&%nxfaNoblJ)O%jkz$#E5unRfNUaANm zVVY?y#>1!%`;o&8P$|(6X5wws{fE|JRL3t-Up9*RvhTPPe_<)^V0x6N8})u4*2ggO z$;>~`m@#&OHqiuCFDA0nG_XskO?QP#38JXy?%^DKgi81kHN{_%!=$s&9-M<((iNx~ z*o8rCK`q&{H0D2>#(OSIP9f^WWKNVg7nLv{HGmS-z}BIratms6ZAT7MN2QLMttajE z9xUSeWz>7asC+LXG_<+iqrPke)!{dL-Nn3Wx4LmI&a{@HeolMQ!4UF_Ifq(;LDc5F ziZgHswHNNAX8aZE10x@3DA5j{k3cVOcrm*sqAQK4IJ}_jN0L_yCZP$XxJaR6+gw5B#0I+n;2;@$;ewQhcsd shd%s);GqY1Tl~Sk?zGp@P3cc!JX_nFT3h^WC85#wbIfov&>41FzCg)2ur$=2<*xmD_c-d^n%ce zq9||dA`u}bM0FcsSAz&c0;`+Af~brFgXsI4C-C9?_j#Uk&h!49{R&T4#HWIpw~W+A zEF;?8X2w;}kBW{K+Z*zy*|Nkq*+01|dk}IT*%rtV1vT z+hHOglo3>gR(`a12T^<0jhg9YJcajBTjXbBi?9ea@LE*87F4?($g9?g z8o)`^z3+f_&G9~(H&F=gQ%q$#wJXnDt<$CJd0fBrd5>-Q1wcX#jpsfgL>3N z_F@O_!!`I2_5K^A-MGCc!lz&9IZ$q%ujLU^JbZ8>!BPlJ--FQ?srk<|oax-nr3=mBcneXQ`1; z()KEq%wbj7pznXHb3^PPstJ7+=>vn>CoP@+HY*6FT4Fn)Q?3*tSV-GK#0YJH_Fr4L zo>)oL5K3WU6|tJ|r%x*xrQ~T(hC6Z1cQ4tL)#pmS%8t4dXFQ=~RUqb0bmvYcyYfa| IiBNFjA7Ey8rvLx| diff --git a/resources/languages/pollora-de_DE.po b/resources/languages/pollora-de_DE.po index ac0531ec..ea1102cb 100644 --- a/resources/languages/pollora-de_DE.po +++ b/resources/languages/pollora-de_DE.po @@ -242,20 +242,20 @@ msgstr "Mehr über die Aktualisierung von Pollora erfahren" #, php-format msgid "No %s found in Trash" -msgstr "" +msgstr "Keine %s im Papierkorb gefunden" #, php-format msgid "%s Archives" -msgstr "" +msgstr "%s-Archive" #, php-format msgid "%s Attributes" -msgstr "" +msgstr "%s-Attribute" #, php-format msgid "Insert into %s" -msgstr "" +msgstr "In %s einfügen" #, php-format msgid "Uploaded to this %s" -msgstr "" +msgstr "Hochgeladen zu: %s" diff --git a/resources/languages/pollora-es_ES.mo b/resources/languages/pollora-es_ES.mo index 55b107d49b02924376fb55a1d8dbb98ab934b633..2aec352718cb434c471f4f64eb8a21159572c2fc 100644 GIT binary patch delta 1803 zcmZY9OGs2v9LMqFm~ZpdyEJd@^{Vpf5IJxBth zMGCe^p+KoN)gsyiMYL!UMUfB{L1a*bk@WqIHwZf1|NWf%nDh9bb7v&~VL{|g*0jrp z(m-Srw#yhFKAFaeawy)I6!hS9?7(w)8WS*yQz~Ynt`~Aj#sbX1Qsh-ri}SF~dESQn zE@A>y)bVM|#6H}QLzswhoMvJgrehw?!d0k->yTGXqhkwdz=xdcqZr4vjcIra)lWBO z(Z9LsJh+LAcyJ%p;9E?>52$2}qdNS7>fkq$ER)1bx=uw6bgp9wYM`~a5L-|gIf1d* zg)aIxJybZ%1x`xc6~{r;KyKk2yo+k^ne+U;<5yH>emSPl2vxHH^*c9eU>i^q*o7Qs zAEyYDFkUK4u>;j%A99#}PHJcvXW(tr^T&?QQ60ZT&Fm9uW)s+ilURX`q(|*^qki9u zdt=Ca3i;Qj`o>OBL%O&K8K{8`pi(=89Oe!ub@T|8i5IBN_Zb)9Pt3xZEVSM$#5}A( zt$8bUU;x$b>ooE|pUOvW%*9wnk%QT&R8^uzUV|D?18N4%j;*N8M^FPeiCXg> z)If%DHQqzDGma$3Ohl-tqd4BugH%)lvrwC>2(@;_sF_qddQmg(!Xg|*z4sK=&KRoQ z�MxLuG0bwUjBOLlcSQQc**NsPA(#>cs=7fgC~2?2L2WgUZM?REIZEpYH=yhp$i_ zj-lTBg6r_7bH9v6^?W0;S`kxEMIE-FM(#s36hM+=I#B~Wi`rZRxD&7A8vKq*WiiuI z!{x}{`me0u&Nf0>-9eNR75e^_4wZajYqUlduz_$BmCik(Oek&t7Ok^IEGIM&WwC%D z(`GwSLi{a@ok}h)Q(fQx5<=hUMnY@6ieOTvoTw)>Yh|E}&>xYqrLuu2BxV!JL@}|R zs3nw1Wl=>NdJCZqSVX93)9VY0X!lkTn+O%wBwFe?vt-e=o4Pag?^w-+)?GzkjLJ%4 zDY2H&n%59o3Ki`QEn~E}!&XhGt=)gz7dEQfc0$O1xYPE9N8)2$$-aV-5(yEkzC@+nQAL&d3}~UY-;RiTfy+u^`-dS q(SxZ~uIY7wFb#V`mM=iVeQ94uz3JmINxM4_`@KQSQ`y^;`R)&(E~d!< delta 1594 zcmYk+Nk~;u7{KxKET?knHJEAHpjMV;XA6$l9~BneyuwXlH~R77Z4s6+!s z5J6CzUJ&K3MN|~hrU@#7k_IRW+t{Z6UvA*xeeZYgefOU8o$s9c9+urHNxYAb?G3ce zL=n-D5<)W`9m|(3ErhcW!*uMzEW8k%U&nEr-@-h+hkU|gz6x*v{rxNY+z>iWjLs&6 zkO(t4XyigAW?%ZQoBhQ8bo@}+pcQz7WAGdL+z+(>UuY%% zMy7Ewo$BZZcNLWEK%ycj#Wez zQ9@J_wo+mNv5?4eEq!%t-MQJt$%|>9V%?n?my@e9+hfTmx}VzRm8tjr-Qge)*6 zC=d&RNQyQS5(Y-hqD2p=RYYVU1VIo%(V`Z8f720y4)edCd(J)gyzhJ}yI3Co;7h+| zNc)K*qQ_%Q5Z|YBA$4UKlZ|IE7ti7q?8jN?=aPe^sC+e-EUdwNY(%P>L+Hn&_PL9^ z7dO4!sN;Stz`J-1$1xMgNXf z=-=G44<@jb2QN_#e#P1N6SWv=WYl2>s)Jl)v5ep5i%@ z!t`)qlBN$UaRAlvW8`O&T-4AjoP%#s&%ao|qdNYLnqdyhqZtrsR=tqjodQ`pbSc!*G zd*6%QIN)Xd^=rZ+J;)HwC zbs3?-Hxt_b>9j=O{}y5~vBH*WA2q|R#8P4%!Q@i2+lQL9PW@U!e@yx^lxm1_Lg!=y zQAKE}cMw_$ZHH1Z(L}5#;uTz!v}am&9hL@S8=<7VRoY8vd+L<2N&l9OguWOheL+gA ziAqA>ybj%VLWfRCfAu;)sT6SMJB^W(q0U&)H38RYj7B4!?cLF!o5;_Y;mHa{qE2Tx zdfEvLc{6X#Y>BkFp~Oh$a9aM6u2ZMmI)WVz52K+@H&K&SYDU&$)B8WU(kTpW(Y> zln!DG(d9Mk!4V%PN+7}PCI&GPBe)L7U3(H!XurS!zC#ZC#Ay>Qpnm^}dd^2D8YhTB zvyfF#>E=Q+CgB)n;X_Qv=eQncQ5`QJhb=n4Vk+$)uI;6hc-kq*02aUuEW|Xd#R6={ zM8>zXRFb)H0X69%)IgU}9S7d3-^R7S2iN0GzsbJFuqQ7`reHGz+)0q2o-uq7})N^$#@J4JyW#Tldp8-@x zMo{;KQ7;rpBmX>WPr2*; zm`!^COYu5t$)1I%lu?;QJ+Ol6IG*Y1Mf|7%GEr}yi)7i#Q1{iKKC?#Dz$Z`>I)@xK z#7TSV2GX@fQ1?xs?hDOO$)_@hn&~nsRes(^17)E$PaZa6DQe(8XFqb-ple@q-a@*v zyQrByMrG(V9>=#>r|&<9JZqq1sDaKPd&dfiwMDa6$tQU5zf!QKwTHSknflU9HJM7H zm8d7QKeQCes>YA~DdCQe}JeViij#geONKEgV;%EQ)nNkge(1d(I*L?J>h#vW6>kYBcAA&loD^W V%HQh^KS*DRz6#8F!jCc*{s8)Yc`X0{ diff --git a/resources/languages/pollora-fr_FR.po b/resources/languages/pollora-fr_FR.po index 3208d8dc..76313ca1 100644 --- a/resources/languages/pollora-fr_FR.po +++ b/resources/languages/pollora-fr_FR.po @@ -244,20 +244,20 @@ msgstr "Propulsé par Pollora" #, php-format msgid "No %s found in Trash" -msgstr "" +msgstr "Aucun(e) %s trouvé(e) dans la corbeille" #, php-format msgid "%s Archives" -msgstr "" +msgstr "Archives : %s" #, php-format msgid "%s Attributes" -msgstr "" +msgstr "Attributs : %s" #, php-format msgid "Insert into %s" -msgstr "" +msgstr "Insérer dans %s" #, php-format msgid "Uploaded to this %s" -msgstr "" +msgstr "Téléversé sur : %s" diff --git a/resources/languages/pollora-it_IT.mo b/resources/languages/pollora-it_IT.mo index 41e817ab3bd9aa5ff6efea1d825277e9e57482b7..2aa96e39e95423d8e61fdd15468944f5a27a5f6a 100644 GIT binary patch delta 1813 zcmZY9e`t+S9LMpqYd>te_S2Y+xy&8Lu#K6YW9G+98I2IywcXm2{m^~xDEs67qnP|C zHz7ijLQ$6db^L)>FzUSQgCjX;9(wh-` z+R$o9d(|^AqfjG4U3UmO(jmiYJ0r_ zc`jmF>1f~%Ovkgh18?AHjN>pCQ!o|tF$+skiK~%YO|5ksYQpvQycy#-4`T`*M2*vl z8H{hv+Y7gF3K#C9628Yo{DdmT0BXSRr~!T>#WIQ9q;oH7qS@9W)I=*W54WKzasWr+ z5%e&=IYx(%Imtn#J7>L$n#gS&kM~dspWEww)-R~a3|f;(Lf7P?p7Wz7whZ+Gn~{&% z&LP4}m_|B<*n%4HEb=jzIVjOh%*4B>>rbpNPy@e1y;(o%&3@u09Kv#}r98^hiF!VY zTVtqvGWFM*d}SvnQ4Bjwn=2hP(@Ut*UPIk?2eoz&P?dOzjA8n59DYOPN#JN= zY~ zet#HebAAT(+#^&)x+8Ry;5BN19^@s=2h`>oKuzo$PQrLzOHM=moGP&pccSh)jLLHk zHP98*3tUJ2Oz)xweu!F%$WuB>^a@qd&!`7t$)<#994LmFin?Bm8elEvU@d0jUewR* z5Nb(|qV7A5i}4~V?>l?FZ{$2;zR=<2%rDdf-i(n7WT6JmM-5Pd+FUib0XJeXUO**& zX6-@t)_-j-->e~2)pdlPE!Xc~>!mi0SUub`W);>DenPYV+cY7S?cb(#_7QUky}7Dc zKu}1tmM9|rwyCx=yeZXZd^3~Krdv+r6Qu+%Vaka0gx)%rC?T|0w2o@ai0OoO{sLka zv4p53R7tJBn)XW-F`t-0sA=i6oDr?rQlf%T)8K`MRUqUv1W4<)FOL35>-_`HNUJsg delta 1594 zcmYk+OGs2v9LMqhOgcWA(V5XwdpOlJnfCHkmRXwi&~Rgvi$bAQEwW8*qGEyw0~Ht3 zg9>ty1Vy_Eqgu2GqKKN64Y?4c5OPr&ZZd+tKj#D<=0Bfv?wothx&L$SOvlpE_+&Wi zrcn+OtB6jo*-5;V#g~fDYzRDvg?K1c4g6!Ni8&PiNKd)l>sVK(h~WP*i3!w_ch0*v8W ztj8SYw{9wVT zWLaqPt=ZG*oiywKI;B7>i#M0_OREeGtVVT|Aj*S?5-vT?xH4mfLifmBwIFyn(#F$)gMp;jiWO71Jys9bZNp6vKEV? zey>H1-;OJ=3rjJ6hKgR7Ysj|Q0IK6HY{U`Nz!UEGY1f`b_4|u@Vjr_=!eZ3GQPi1f z!cJ_#8XQ86_bOxD-c#Ys*g9fyQ74r(#12&!6}`XzODz>;NQ=?dDSO+9eS{9F7Nawz z?5Svxta;%NPyau*o5p@(7onnbZCt2jGNSCLXrFZ^l>T~RanW9HcNY#|1<^w2wNw^W zlns@Y8LSc;^#1R47epJOgQ3Hs6W>f|pSKeF+l&$_N@Xjde>Rm31c|gg#1=xQpCe&S zL>;l7s3ufm#3n)q+ONISflx^V1Cit%-&ap!F!x@vDer10PR K=}Gv*6aN6h%6P~C diff --git a/resources/languages/pollora-it_IT.po b/resources/languages/pollora-it_IT.po index cf79cb57..f765b6a2 100644 --- a/resources/languages/pollora-it_IT.po +++ b/resources/languages/pollora-it_IT.po @@ -242,20 +242,20 @@ msgstr "Scopri di più sull'aggiornamento di Pollora" #, php-format msgid "No %s found in Trash" -msgstr "" +msgstr "Nessun %s trovato nel cestino" #, php-format msgid "%s Archives" -msgstr "" +msgstr "Archivio di %s" #, php-format msgid "%s Attributes" -msgstr "" +msgstr "Attributi di %s" #, php-format msgid "Insert into %s" -msgstr "" +msgstr "Inserisci in %s" #, php-format msgid "Uploaded to this %s" -msgstr "" +msgstr "Caricato su: %s" diff --git a/resources/languages/pollora-ja.mo b/resources/languages/pollora-ja.mo index 50c0cd6fb7ccb94b4f2a0750c1dbde7d1631f625..4ed407aadf2a700cde60f4856c0624c3a6310077 100644 GIT binary patch delta 1822 zcmZY9X-Jeo9LMo-Yc<{V+U`T!jo=jp|MHLa0dk{vIa?8umY*d3K(indd+A?1gd9CPX@XeNP$6 zYGMQtb{P}I7k#NHTa%6PU^%AaPCSbBn1ZQPUd%>a=TfC%9%f+ya;quEp;&7BE0Ets z%q|+5xE{0dEN;Q;*bm)Q126+KaSRT`Le#*e$gO6bbrWjATkUlXy15Qx2JT1A6GI>K zoAb7#4M)*&A2r~6OvR6=WPC$S*o~UtHVMo zcnDKTVuc1+Z&6S73H4+@aXt3nY+OfrG)@fl`zBnQ zMCLu@UwiS1lb{s-!Yi1=)(u7L-rbVUk47u&-U8Q#*X7U8;9T=RHimy9#+}w z<9LDV7E~rmGReR8d=;~e#TuN5Cr}HxN2Lj0pnmYi`X2e1E-F1)H|jp6K@0Js#w$WC zWHU~}TGa2(qaLg^LPG;TKyAfSWO3#tYT$P`20!97^pGw&59i|!EW~T52Wdx**JE`v zi%!26^}P=@@lYylMPxFKku-{M5SF2yWCv=c`;f8BVH}RZRwSanS12Rv<#AxKuaSAm+ytl(VK@IfEe*XbAz;~>{AGij$bF_6f;;8;d z$l3a@C~M0Jy|OEb0%Eq_f5vf&_G_7Km*8q5K+Lt@32lwi_HWUPfleE~_gKRmG3>izQ`k++9;!UKyj@zqGuA( O8yYMciy!d~O!@;ceZsi_ delta 1596 zcmYk+OGp(_9LMqhTebS;d{pMM@|xQ1VGnz{uthB*f|7PcN{b>$lN+@NDRE&> z(%M)cv>T(>LfVwAZkviAj9LUn2uYi)Mc?0e0uS?_&p9)59&^r_d0jhFotVl`ya|*L4e{h(kDdm0q|B0H3Kh7{0 z)9Bx#WQwpD)nFs)!F|qysF^tG>_!gj{tKipdxdqFL?*@N-2D&_ zCCFQZjBXS;%TXOxquwZvdZTtcfE~CI@1g2{K-K?(`#shgA5xC9;A&?IwFEP$J>W&= zXErCo{FiW{#9e5?YRYZ66i=dNS?48n?<$fF@38r z3mdQjTd@QmP1L) z!{i1(JoRr|DC{OS5lUL`njkm-83oHgS^oxo=bHXzVqwyE-r_FoLG7U(gg(p7gp$4> zB`t@NmLab1f164!?LzI`QbOyyozQP|9iiW*Hj`2#v6Em{gH*+tm9(wIT0;K?Esr)^ z6QRuqV#S)s8TgnTv$TcoI!4p(GU| zB6uJ)F+m~~>x;yard}kLcxaG#C_#i+zP~yWiIe+3pL4gf{O`GE^J4z!%glkt45f+~ zMYtYg9DF#Cic+6oOezL&5N^Q(xC;|8nJNu4Q0JqmQZOIWaT@Zdslee_Y40~8??p|B zh6dh+8Q6jA@d74cJk=2N;$Y0fp;&_IxDt8PR9kCN6Rx-Cn=zhq7rnR>HBK95GQK%s zZ(PQ)+_;JA@D(QG8&onrpa%Sc8sG<#ER)PbI!{AQG{;(unrH=%##&THcHjWqhaSc^ z`)P2PLsUxLaqC&sL^?4WucJDAV(<4_`%szrZcU{Vnr0;GJwIwr5^(u+#fXVe5zxTtTNftpAzDq{txflE-Es}y;~l;be0Mt!Pgg@G=6Y-|^q@L=flB3j)XMu%n=_6RnTPsomSP?@pjNyW_1;ly2WrA6 zQ3IYsWxNwLk?0K?`Y-RIX8atr(vQ|($k-;0{ApsNP#qPbGB*?T-YQha8d2|WLJhnF zi*S!Uzl55=HRQRdxkDq93y)D9yhf$`EpnJoR7zUg!YQkt5QZxAe5b%gtAdeR1nI9 zvZ<0wEFoqP6A6_Ogg#qTyLUdZkWkSkQmG`A{<%7_MH_Dsp+7_==MQEo77()u?TvCm zpF~BQRQspD_#?jZaAQkwyAv^LyKZ7Q*s#rYB3BXycv76O>kEe5R-eE9XX5Gjwc$Xd zC3Y~WEiQfC*3GSfCa1~AO}8Z&iDjh}dUE`c;()qyHqu*hz;*w07CMqJF18^p+vBYa q`4}b`+TQ9kJE8V%-e<9*!RO;Lmpk{m`g`<+0)g;69%}DS@A(Z9)~lib delta 1594 zcmYk+OGs2v9LMqh%-HzI*G#2prhJuUmT6CW;36f0ii!xz7DkH*%3^|qq7sQI zii`@HjnGDJ5E-;fYGt^{+(r z^V>cu={z`$`sg;)MD3`7&mc*$ORjwxwZLxF!jf2o{iqC#U;xKa{l1~b|BlMUUuTeq zsmyO7D*0G|8n7Dm;tppkDiepC?Z{;pxvAe3)Pf$O7XB2~{{=Fa4PXohk)+t9``yn= zaq5;uML*;_i%}CsQD0Px`l6lKg3Y)NAE5dVqxz5GULSjnH)%Jp;abpJ)ZPzs(|Dh7 z4*tv}{|l(3XH5?fL8Y=BwO7r^We45!lc+ttiu>^%a@hnoWx`Jih$lzGCUrqqA$3H>TuV2AGN|qsEK-!teS^fz)RGh*8pmvA5dTR%Nbx+s+Nmd zPzh?BN>rxmQ2pX9RFt9F0djn~bPG(Ge{5Vp;kA#Gah@-tOE^ JUt%mY^bdkSc{2b2 diff --git a/resources/languages/pollora-nl_NL.po b/resources/languages/pollora-nl_NL.po index ca63b2e5..47c31c02 100644 --- a/resources/languages/pollora-nl_NL.po +++ b/resources/languages/pollora-nl_NL.po @@ -242,20 +242,20 @@ msgstr "Meer informatie over het bijwerken van Pollora" #, php-format msgid "No %s found in Trash" -msgstr "" +msgstr "Geen %s gevonden in de prullenbak" #, php-format msgid "%s Archives" -msgstr "" +msgstr "%s-archief" #, php-format msgid "%s Attributes" -msgstr "" +msgstr "%s-attributen" #, php-format msgid "Insert into %s" -msgstr "" +msgstr "In %s invoegen" #, php-format msgid "Uploaded to this %s" -msgstr "" +msgstr "Geüpload naar: %s" diff --git a/resources/languages/pollora-pt_BR.mo b/resources/languages/pollora-pt_BR.mo index a9397c396a4dc5054eeafa81aacbd02640a17aa3..42428bf8612bccbaa4bcd75f997ff06512150227 100644 GIT binary patch delta 1806 zcmZY9TSyd99LMqFnwq+8YBy^u$8xoM+12j3UDX~m3^EAZ+98+Hwb{uiiKVyf3Q1w~ z(nGx%6lD<s85OkRT`JA&ebMCVj%dVHFUlt8HZAf** zc)|}D|>PV<+ZdE?E!@QT2&rd037GScyDpqBs_7?EM|cduh`~ zK^=EuA@<-_?8Tv&MK%&cI2Zc2f=--^P zH!fo-H}0Sse1*CA4z(DcQ5}9ob?^&WER)Mass~X6Ew;`?4K#`qaSLii_F*O-#sK}B zV-&c|Nir?nS?dMVK(62zyoqY?k-h)c`U$l%Kdt#RLeYd#??q4pTaKE*Cgd_rWN9X0 z;uNM~E2_gDVbp`ykT%RM)ZX1g?cGz<%=%GFKY-&fkB2mn2&%n>=-@ik1a@LO zcB0z-5MupDQ}|{d2=P`i^+~9uT8bKZ6g8lYs1<9p^=+ub>+9* zPwel{Py>8p>*+5P^!fcn&Agbq`kpIM4X!{I$21_9X(rQs5A}Q+!`OrB=sId?Z=+^< zAD7`H)O$f1)%~$ZJ#ES;(6^~XjXa8Ks1~&n4X6%csKa#-oA3~>#22VD5N3nbK?QQQ z{!6p@WgVeUb^}pKRO$QI!B8qA)()18S&el>gjiyK6Iuxk^53L=c8FQT;LNa`(4^KA zGl{=xk}YT}XQ)j7W-6i6yn>ia%p;hTSxD3pnzdG7E}=7_St>0jDhM6m1%wXRQX)!d z>9vYV6Noj$d}0crq_0K)C21WJ?T?mHNvBw;hG5IgVpVJ^#Z`n3q>}buX$~=sSVZVl zR}$|CI*_nYn*Yll3o1b(d-2*x2vl_iv zs-^G5(5{Sv&F#CBvA7#|xaqefQhg)xssa8;M)Q`V&Q#vUyp)b+Wtx`eEt{^8i<&4XD6mBYVT4Fw7*iCqNO55% zB-F+hQLRRbjJ9GRNf1R80~G|h2$I|wLH}nwi#g0YzjMwv=YGDK3O%Zf#sjHiMmj*0 z5Zzw09vn>NODWB47(AGcBbbfjt~`lZl%HZAzCb=U%U2Q3qn`gnwfln_$3tg+v#6Dl zIYmW1X5bxMi+3>>ALBZFiL|wOLLhbu4QD$2{q0Etcm5TNrMtilRx!gR*B z6J#=}ID=aBdDK9?sE+%QO|k2)d=oXn5!A#cu@t9KJMalpZ~@is7pni?sGa!hOrtWL z@y$=B0E2DP#A#SO=4f_&e~R@Y%MBpP4Kt`zhkn$Z38MNbM?O~L>RV7p(}86e zLA@1y7{Z|_8Qsnps)HHSMBbui{@&GpMosLyD=(p5qkpKC7PDRKsx_ke>q6#g$58Fg zy663<-`~I>M(>l+K+jQI`5LvtS!~C5sE%{!MbCq%atJlyMpXMYWG)s)bsRz6nTvQ5 z`*1hTq28)&W=TI$D{x`{|mde3*biv3tkbP+oHRzgX; zp`_hW+C(%nzICeP(q613))Trg+A>|3Izs2Ykx*(N!i3IRDMYZd) Date: Thu, 12 Nov 2026 16:44:33 +0100 Subject: [PATCH 07/10] chore: require pollora/i18n ^0.1.1 and pollora/attributes ^0.1.2 pollora/i18n resolves the Translatable markers and runs the extraction commands; pollora/attributes 0.1.2 ships resources/i18n.php, which the test that guards the translated fields reads. --- composer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2fff77ad..e56fbf96 100644 --- a/composer.json +++ b/composer.json @@ -32,13 +32,14 @@ "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", "pollora/assets": "^0.1", - "pollora/attributes": "^0.1.1 || ^1.0", + "pollora/attributes": "^0.1.2 || ^1.0", "pollora/block-bindings": "^0.2", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^2.0", "pollora/media": "^0.8", "pollora/meta": "^0.1", "pollora/hook": "^2.0.1", + "pollora/i18n": "^0.1.1", "pollora/option": "^1.1", "pollora/roles": "^0.2", "pollora/schedule": "^1.0", @@ -107,6 +108,7 @@ } }, "suggest": { + "nikic/php-parser": "^5.0, needed by the pollora:i18n:extract command to read the attributes of PHP files (console only, never loaded by a web request)", "pollora/woocommerce": "WooCommerce's Blade templates, Route::wp() conditions, Coming Soon page and events", "pollora/wp-rocket": "Keeps WP Rocket from writing .htaccess and setting WP_CACHE unless the project asks for it" }, From 92a09a4d5ab4b926e88671638fbdcdc831e6739c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 12 Nov 2026 16:56:37 +0100 Subject: [PATCH 08/10] test(translation): give the load_textdomain stand-in WordPress's signature The stand-in took two parameters, so Rector, which only knew that declaration, read the third argument the e2e fixture passes as an extra one and wanted it removed. It takes the optional locale WordPress has, and the closure of the catalogue helper declares its return type. --- tests/Unit/Translation/DefaultLabelsTest.php | 2 +- tests/Unit/Translation/TranslatingEntityRegistryTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Translation/DefaultLabelsTest.php b/tests/Unit/Translation/DefaultLabelsTest.php index 75875856..4a778f9d 100644 --- a/tests/Unit/Translation/DefaultLabelsTest.php +++ b/tests/Unit/Translation/DefaultLabelsTest.php @@ -34,7 +34,7 @@ function determine_locale(): string } if (! function_exists('load_textdomain')) { - function load_textdomain(string $domain, string $mofile): bool + function load_textdomain(string $domain, string $mofile, ?string $locale = null): bool { $GLOBALS['i18nLoader']['loads'][] = $domain.'|'.$mofile; diff --git a/tests/Unit/Translation/TranslatingEntityRegistryTest.php b/tests/Unit/Translation/TranslatingEntityRegistryTest.php index 095b0238..7cc2d657 100644 --- a/tests/Unit/Translation/TranslatingEntityRegistryTest.php +++ b/tests/Unit/Translation/TranslatingEntityRegistryTest.php @@ -36,7 +36,7 @@ function i18nWiringCatalogue(array $words): void { $GLOBALS['i18nWiring'] = ['calls' => [], 'words' => $words, 'filters' => []]; - $lookup = function (string $key, string $text) { + $lookup = function (string $key, string $text): string { $GLOBALS['i18nWiring']['calls'][] = $key; return i18nWiringFiltered($GLOBALS['i18nWiring']['words'][$key] ?? $text, $text, explode('|', $key)[0]); From dc5c409a84e8109d71928e67ba1efe37eb02dd46 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 13 Nov 2026 19:53:46 +0100 Subject: [PATCH 09/10] test(translation): share one stand-in for determine_locale() and load_textdomain() --- .../Providers/PolloraServiceProviderTest.php | 52 +++--------------- tests/Unit/Translation/DefaultLabelsTest.php | 35 +++--------- tests/Unit/wordpress-i18n-stand-ins.php | 53 +++++++++++++++++++ 3 files changed, 67 insertions(+), 73 deletions(-) create mode 100644 tests/Unit/wordpress-i18n-stand-ins.php diff --git a/tests/Unit/Providers/PolloraServiceProviderTest.php b/tests/Unit/Providers/PolloraServiceProviderTest.php index 29ecd491..434aef4b 100644 --- a/tests/Unit/Providers/PolloraServiceProviderTest.php +++ b/tests/Unit/Providers/PolloraServiceProviderTest.php @@ -12,50 +12,10 @@ use Pollora\Route\Infrastructure\Providers\RouteServiceProvider; use Pollora\WordPress\WordPressServiceProvider; -if (! defined('WP_LANG_DIR')) { - define('WP_LANG_DIR', sys_get_temp_dir().'/pollora-wp-lang'); -} - -/** - * What the text domain loading asks of WordPress. - * - * The two WordPress functions below are real, not Brain Monkey stubs: a stub - * defined during a test outlives it, and the first test that checks - * function_exists('determine_locale') afterwards would call a stub with no - * expectation. With the defaults reset after each test, they behave as - * WordPress does when nothing was customised: an en_US site, no translation - * file loaded. - */ -final class PolloraTextDomainProbe -{ - public static string $locale = 'en_US'; - - /** @var null|callable(string, string): bool */ - public static $loader; - - public static function reset(): void - { - self::$locale = 'en_US'; - self::$loader = null; - } -} - -if (! function_exists('determine_locale')) { - function determine_locale(): string - { - return PolloraTextDomainProbe::$locale; - } -} - -if (! function_exists('load_textdomain')) { - function load_textdomain(string $domain, string $mofile, ?string $locale = null): bool - { - return PolloraTextDomainProbe::$loader === null ? false : (PolloraTextDomainProbe::$loader)($domain, $mofile); - } -} +require_once __DIR__.'/../wordpress-i18n-stand-ins.php'; afterEach(function (): void { - PolloraTextDomainProbe::reset(); + WordPressI18nStandIn::reset(); Mockery::close(); }); @@ -93,8 +53,8 @@ function load_textdomain(string $domain, string $mofile, ?string $locale = null) it('prefers the translations a site provides over the bundled ones', function (): void { $loaded = []; - PolloraTextDomainProbe::$locale = 'fr_FR'; - PolloraTextDomainProbe::$loader = (function (string $domain, string $file) use (&$loaded): bool { + WordPressI18nStandIn::$locale = 'fr_FR'; + WordPressI18nStandIn::$loader = (function (string $domain, string $file) use (&$loaded): bool { $loaded[] = [$domain, $file]; return true; @@ -107,8 +67,8 @@ function load_textdomain(string $domain, string $mofile, ?string $locale = null) it('falls back to the bundled translations when the site has none', function (): void { $loaded = []; - PolloraTextDomainProbe::$locale = 'de_DE'; - PolloraTextDomainProbe::$loader = (function (string $domain, string $file) use (&$loaded): bool { + WordPressI18nStandIn::$locale = 'de_DE'; + WordPressI18nStandIn::$loader = (function (string $domain, string $file) use (&$loaded): bool { $loaded[] = $file; return false; diff --git a/tests/Unit/Translation/DefaultLabelsTest.php b/tests/Unit/Translation/DefaultLabelsTest.php index 4a778f9d..d1cd28d6 100644 --- a/tests/Unit/Translation/DefaultLabelsTest.php +++ b/tests/Unit/Translation/DefaultLabelsTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Brain\Monkey\Functions; use Pollora\Attributes\PostType; use Pollora\Attributes\PostType\Labels; use Pollora\Attributes\Taxonomy; @@ -21,26 +20,7 @@ use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Spatie\StructureDiscoverer\Data\DiscoveredClass; -/* - * Stand-ins for the two WordPress functions that load a catalogue. They are real functions, not Brain Monkey - * stubs: a stub would leave the function defined for every test that runs after, each of them then failing - * on a call it did not mock. - */ -if (! function_exists('determine_locale')) { - function determine_locale(): string - { - return $GLOBALS['i18nLoader']['locale'] ?? 'en_US'; - } -} - -if (! function_exists('load_textdomain')) { - function load_textdomain(string $domain, string $mofile, ?string $locale = null): bool - { - $GLOBALS['i18nLoader']['loads'][] = $domain.'|'.$mofile; - - return false; - } -} +require_once __DIR__.'/../wordpress-i18n-stand-ins.php'; /* * The default labels of a post type or a taxonomy are built on init, from the singular and @@ -445,24 +425,25 @@ function i18nTextsArguments(): array describe('catalogue of the framework', function (): void { beforeEach(function (): void { unset($GLOBALS['l10n']['pollora']); - $GLOBALS['i18nLoader'] = ['locale' => 'en_US', 'loads' => []]; + WordPressI18nStandIn::reset(); }); afterEach(function (): void { - unset($GLOBALS['l10n']['pollora'], $GLOBALS['i18nLoader']); + unset($GLOBALS['l10n']['pollora']); + WordPressI18nStandIn::reset(); }); it('is loaded, the site translations first, when the default labels are built, and asked once per locale', function (): void { i18nWiringCatalogue([]); unset($GLOBALS['l10n']['pollora']); - $GLOBALS['i18nLoader']['locale'] = 'xx_FIRST'; + WordPressI18nStandIn::$locale = 'xx_FIRST'; $registry = new TranslatingEntityRegistry(i18nWiringRecorder()); $arguments = ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')]; $registry->register('book', $arguments, []); $registry->register('movie', $arguments, []); - expect($GLOBALS['i18nLoader']['loads'])->toBe([ + expect(WordPressI18nStandIn::$loads)->toBe([ 'pollora|'.WP_LANG_DIR.'/pollora/pollora-xx_FIRST.mo', 'pollora|'.dirname(__DIR__, 3).'/resources/languages/pollora-xx_FIRST.mo', ]); @@ -470,11 +451,11 @@ function i18nTextsArguments(): array it('is not loaded again when it is', function (): void { i18nWiringCatalogue([]); - $GLOBALS['i18nLoader']['locale'] = 'xx_SECOND'; + WordPressI18nStandIn::$locale = 'xx_SECOND'; $GLOBALS['l10n']['pollora'] = new stdClass; (new TranslatingEntityRegistry(i18nWiringRecorder()))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')], []); - expect($GLOBALS['i18nLoader']['loads'])->toBe([]); + expect(WordPressI18nStandIn::$loads)->toBe([]); }); }); diff --git a/tests/Unit/wordpress-i18n-stand-ins.php b/tests/Unit/wordpress-i18n-stand-ins.php new file mode 100644 index 00000000..e2add81a --- /dev/null +++ b/tests/Unit/wordpress-i18n-stand-ins.php @@ -0,0 +1,53 @@ + the "domain|file" of every load_textdomain() call */ + public static array $loads = []; + + /** @var null|callable(string, string): bool */ + public static $loader; + + public static function reset(): void + { + self::$locale = 'en_US'; + self::$loads = []; + self::$loader = null; + } + } +} + +if (! function_exists('determine_locale')) { + function determine_locale(): string + { + return WordPressI18nStandIn::$locale; + } +} + +if (! function_exists('load_textdomain')) { + function load_textdomain(string $domain, string $mofile, ?string $locale = null): bool + { + WordPressI18nStandIn::$loads[] = $domain.'|'.$mofile; + + return WordPressI18nStandIn::$loader === null ? false : (WordPressI18nStandIn::$loader)($domain, $mofile); + } +} From cf5b22af8c753f370ff40bc03915228076b05884 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 13 Nov 2026 19:56:34 +0100 Subject: [PATCH 10/10] test(translation): follow the default labels built on init in the discovery tests, and isolate the provider locale tests --- .../PostType/PostTypeDiscoveryRulesTest.php | 5 +++-- .../Providers/PolloraServiceProviderTest.php | 20 ++++++++++++++----- .../Taxonomy/TaxonomyDiscoveryRulesTest.php | 5 +++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/Unit/PostType/PostTypeDiscoveryRulesTest.php b/tests/Unit/PostType/PostTypeDiscoveryRulesTest.php index b19f45da..00b1936d 100644 --- a/tests/Unit/PostType/PostTypeDiscoveryRulesTest.php +++ b/tests/Unit/PostType/PostTypeDiscoveryRulesTest.php @@ -9,6 +9,7 @@ use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\PostType\Domain\Contracts\PostTypeServiceInterface; use Pollora\PostType\Infrastructure\Services\PostTypeDiscovery; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Psr\Log\LoggerInterface; use Spatie\StructureDiscoverer\Data\DiscoveredClass; use Spatie\StructureDiscoverer\Data\DiscoveredInterface; @@ -93,7 +94,7 @@ function (string $slug, string $singular, string $plural, array $args, int $prio ->and($registered[0]['singular'])->toBe('Content book review') ->and($registered[0]['plural'])->toBe('Content book reviews') ->and($registered[0]['priority'])->toBe(5) - ->and($registered[0]['args']['labels']['name'])->toBe('Content book reviews') + ->and($registered[0]['args']['labels'])->toBe([TranslatingEntityRegistry::DEFAULT_LABELS => ['post_type', 'Content book review', 'Content book reviews']]) ->and($registered[0]['args']['text_domain'])->toBe('pollora'); }); @@ -101,7 +102,7 @@ function (string $slug, string $singular, string $plural, array $args, int $prio [$registered] = contentRunPostTypeDiscovery([ContentFilmWithArgs::class]); expect($registered[0]['args'])->toMatchArray(['menu_position' => 12, 'public' => true]) - ->and($registered[0]['args']['labels']['singular_name'])->toBe('Movie'); + ->and($registered[0]['args']['labels'])->toBe([TranslatingEntityRegistry::DEFAULT_LABELS => ['post_type', 'Movie', 'Movies']]); }); it('only picks up concrete classes that carry the attribute', function (): void { diff --git a/tests/Unit/Providers/PolloraServiceProviderTest.php b/tests/Unit/Providers/PolloraServiceProviderTest.php index 434aef4b..7557d1fa 100644 --- a/tests/Unit/Providers/PolloraServiceProviderTest.php +++ b/tests/Unit/Providers/PolloraServiceProviderTest.php @@ -14,8 +14,18 @@ require_once __DIR__.'/../wordpress-i18n-stand-ins.php'; +/* + * loadFrameworkTextDomain() remembers the locales it has already tried for the whole process, and does nothing + * when the catalogue is loaded: the locales used here (it_IT, nl_NL) are used by no other test, and the + * catalogue is forgotten before each test. + */ +beforeEach(function (): void { + unset($GLOBALS['l10n']['pollora']); +}); + afterEach(function (): void { WordPressI18nStandIn::reset(); + unset($GLOBALS['l10n']['pollora']); Mockery::close(); }); @@ -53,7 +63,7 @@ it('prefers the translations a site provides over the bundled ones', function (): void { $loaded = []; - WordPressI18nStandIn::$locale = 'fr_FR'; + WordPressI18nStandIn::$locale = 'it_IT'; WordPressI18nStandIn::$loader = (function (string $domain, string $file) use (&$loaded): bool { $loaded[] = [$domain, $file]; @@ -62,12 +72,12 @@ (new PolloraServiceProvider(new Application(sys_get_temp_dir().'/pollora-provider-'.uniqid())))->boot(); - expect($loaded)->toBe([['pollora', WP_LANG_DIR.'/pollora/pollora-fr_FR.mo']]); + expect($loaded)->toBe([['pollora', WP_LANG_DIR.'/pollora/pollora-it_IT.mo']]); }); it('falls back to the bundled translations when the site has none', function (): void { $loaded = []; - WordPressI18nStandIn::$locale = 'de_DE'; + WordPressI18nStandIn::$locale = 'nl_NL'; WordPressI18nStandIn::$loader = (function (string $domain, string $file) use (&$loaded): bool { $loaded[] = $file; @@ -77,8 +87,8 @@ (new PolloraServiceProvider(new Application(sys_get_temp_dir().'/pollora-provider-'.uniqid())))->boot(); expect($loaded)->toHaveCount(2) - ->and($loaded[0])->toBe(WP_LANG_DIR.'/pollora/pollora-de_DE.mo') - ->and($loaded[1])->toEndWith('/resources/languages/pollora-de_DE.mo') + ->and($loaded[0])->toBe(WP_LANG_DIR.'/pollora/pollora-nl_NL.mo') + ->and($loaded[1])->toEndWith('/resources/languages/pollora-nl_NL.mo') ->and(file_exists($loaded[1]))->toBeTrue(); }); }); diff --git a/tests/Unit/Taxonomy/TaxonomyDiscoveryRulesTest.php b/tests/Unit/Taxonomy/TaxonomyDiscoveryRulesTest.php index 3db492a7..00a7ca75 100644 --- a/tests/Unit/Taxonomy/TaxonomyDiscoveryRulesTest.php +++ b/tests/Unit/Taxonomy/TaxonomyDiscoveryRulesTest.php @@ -9,6 +9,7 @@ use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Taxonomy\Domain\Contracts\TaxonomyServiceInterface; use Pollora\Taxonomy\Infrastructure\Services\TaxonomyDiscovery; +use Pollora\Translation\Infrastructure\Adapters\TranslatingEntityRegistry; use Psr\Log\LoggerInterface; use Spatie\StructureDiscoverer\Data\DiscoveredClass; use Spatie\StructureDiscoverer\Data\DiscoveredInterface; @@ -94,7 +95,7 @@ function (string $slug, string|array $objectType, string $singular, string $plur ->and($registered[0]['singular'])->toBe('Content book shelf') ->and($registered[0]['plural'])->toBe('Content book shelves') ->and($registered[0]['priority'])->toBe(5) - ->and($registered[0]['args']['labels']['name'])->toBe('Content book shelves') + ->and($registered[0]['args']['labels'])->toBe([TranslatingEntityRegistry::DEFAULT_LABELS => ['taxonomy', 'Content book shelf', 'Content book shelves']]) ->and($registered[0]['args']['text_domain'])->toBe('pollora'); }); @@ -109,7 +110,7 @@ function (string $slug, string|array $objectType, string $singular, string $plur [$registered] = contentRunTaxonomyDiscovery([ContentFilmGenre::class]); expect($registered[0]['args']['rewrite'])->toBe(['slug' => 'genres']) - ->and($registered[0]['args']['labels']['singular_name'])->toBe('Genre'); + ->and($registered[0]['args']['labels'])->toBe([TranslatingEntityRegistry::DEFAULT_LABELS => ['taxonomy', 'Genre', 'Genres']]); }); it('only picks up concrete classes that carry the attribute', function (): void {