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" }, diff --git a/resources/languages/pollora-de_DE.mo b/resources/languages/pollora-de_DE.mo index 65591eb9..c1a48d42 100644 Binary files a/resources/languages/pollora-de_DE.mo and b/resources/languages/pollora-de_DE.mo differ diff --git a/resources/languages/pollora-de_DE.po b/resources/languages/pollora-de_DE.po index 6a810648..ea1102cb 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 "Keine %s im Papierkorb gefunden" + +#, php-format +msgid "%s Archives" +msgstr "%s-Archive" + +#, php-format +msgid "%s Attributes" +msgstr "%s-Attribute" + +#, php-format +msgid "Insert into %s" +msgstr "In %s einfügen" + +#, php-format +msgid "Uploaded to this %s" +msgstr "Hochgeladen zu: %s" diff --git a/resources/languages/pollora-es_ES.mo b/resources/languages/pollora-es_ES.mo index 55b107d4..2aec3527 100644 Binary files a/resources/languages/pollora-es_ES.mo and b/resources/languages/pollora-es_ES.mo differ diff --git a/resources/languages/pollora-es_ES.po b/resources/languages/pollora-es_ES.po index 3a64779d..1e0b4244 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 "No se encontraron %s en la papelera" + +#, php-format +msgid "%s Archives" +msgstr "Archivo de %s" + +#, php-format +msgid "%s Attributes" +msgstr "Atributos de %s" + +#, php-format +msgid "Insert into %s" +msgstr "Insertar en %s" + +#, php-format +msgid "Uploaded to this %s" +msgstr "Subido a: %s" diff --git a/resources/languages/pollora-fr_FR.mo b/resources/languages/pollora-fr_FR.mo index e8676a68..ae9b9b4e 100644 Binary files a/resources/languages/pollora-fr_FR.mo and b/resources/languages/pollora-fr_FR.mo differ diff --git a/resources/languages/pollora-fr_FR.po b/resources/languages/pollora-fr_FR.po index b03f8425..76313ca1 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 "Aucun(e) %s trouvé(e) dans la corbeille" + +#, php-format +msgid "%s Archives" +msgstr "Archives : %s" + +#, php-format +msgid "%s Attributes" +msgstr "Attributs : %s" + +#, php-format +msgid "Insert into %s" +msgstr "Insérer dans %s" + +#, php-format +msgid "Uploaded to this %s" +msgstr "Téléversé sur : %s" diff --git a/resources/languages/pollora-it_IT.mo b/resources/languages/pollora-it_IT.mo index 41e817ab..2aa96e39 100644 Binary files a/resources/languages/pollora-it_IT.mo and b/resources/languages/pollora-it_IT.mo differ diff --git a/resources/languages/pollora-it_IT.po b/resources/languages/pollora-it_IT.po index 84c77a14..f765b6a2 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 "Nessun %s trovato nel cestino" + +#, php-format +msgid "%s Archives" +msgstr "Archivio di %s" + +#, php-format +msgid "%s Attributes" +msgstr "Attributi di %s" + +#, php-format +msgid "Insert into %s" +msgstr "Inserisci in %s" + +#, php-format +msgid "Uploaded to this %s" +msgstr "Caricato su: %s" diff --git a/resources/languages/pollora-ja.mo b/resources/languages/pollora-ja.mo index 50c0cd6f..4ed407aa 100644 Binary files a/resources/languages/pollora-ja.mo and b/resources/languages/pollora-ja.mo differ diff --git a/resources/languages/pollora-ja.po b/resources/languages/pollora-ja.po index ec900907..6fd19db3 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 "ゴミ箱に%sが見つかりませんでした" + +#, php-format +msgid "%s Archives" +msgstr "%sアーカイブ" + +#, php-format +msgid "%s Attributes" +msgstr "%sの属性" + +#, php-format +msgid "Insert into %s" +msgstr "%sに挿入" + +#, php-format +msgid "Uploaded to this %s" +msgstr "この%sにアップロード" diff --git a/resources/languages/pollora-nl_NL.mo b/resources/languages/pollora-nl_NL.mo index 1df4b00f..ca8b3e80 100644 Binary files a/resources/languages/pollora-nl_NL.mo and b/resources/languages/pollora-nl_NL.mo differ diff --git a/resources/languages/pollora-nl_NL.po b/resources/languages/pollora-nl_NL.po index a8043cc2..47c31c02 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 "Geen %s gevonden in de prullenbak" + +#, php-format +msgid "%s Archives" +msgstr "%s-archief" + +#, php-format +msgid "%s Attributes" +msgstr "%s-attributen" + +#, php-format +msgid "Insert into %s" +msgstr "In %s invoegen" + +#, php-format +msgid "Uploaded to this %s" +msgstr "Geüpload naar: %s" diff --git a/resources/languages/pollora-pt_BR.mo b/resources/languages/pollora-pt_BR.mo index a9397c39..42428bf8 100644 Binary files a/resources/languages/pollora-pt_BR.mo and b/resources/languages/pollora-pt_BR.mo differ diff --git a/resources/languages/pollora-pt_BR.po b/resources/languages/pollora-pt_BR.po index c47f4290..c72e0b3e 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 "Nenhum %s encontrado na lixeira" + +#, php-format +msgid "%s Archives" +msgstr "Arquivo de %s" + +#, php-format +msgid "%s Attributes" +msgstr "Atributos de %s" + +#, php-format +msgid "Insert into %s" +msgstr "Inserir em %s" + +#, php-format +msgid "Uploaded to this %s" +msgstr "Enviado para: %s" 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 "" 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/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/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index d1ec4632..a4e944c0 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; @@ -152,26 +153,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/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/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 new file mode 100644 index 00000000..4073f2f6 --- /dev/null +++ b/src/Translation/Infrastructure/Adapters/TranslatingEntityRegistry.php @@ -0,0 +1,296 @@ + + */ + public const array TRANSLATED_ARGUMENTS = ['label', 'description', 'title_placeholder', 'featured_image']; + + public function __construct(private EntityRegistryPort $registry) {} + + /** + * 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]]; + } + + /** + * 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 + * @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; + + if (isset($args['labels'][self::DEFAULT_LABELS])) { + [$args, $names] = $this->withTranslatedTexts($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 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]); + + $singular = $this->translateText($rawSingular, $domain); + $plural = $this->translateText($rawPlural, $domain); + [$args, $labels] = $this->withTranslatedExplicitTexts($args, $labels, $domain); + + $args['labels'] = array_merge( + $kind === self::TAXONOMY + ? $this->taxonomyLabels($singular, $plural) + : $this->postTypeLabels($singular, $plural), + $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]; + } + + /** + * 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]; + } + + /** + * A text written in the source language, translated in the given text domain. + */ + private function translateText(string $text, string $domain): string + { + 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 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): array + { + return [ + 'name' => $plural, + 'singular_name' => $singular, + /* translators: %s: post type general name (plural) */ + 'add_new' => __('Add New', 'pollora'), + /* translators: %s: post type singular name */ + 'add_new_item' => sprintf(__('Add New %s', 'pollora'), $singular), + /* translators: %s: post type singular name */ + 'edit_item' => sprintf(__('Edit %s', 'pollora'), $singular), + /* translators: %s: post type singular name */ + 'new_item' => sprintf(__('New %s', 'pollora'), $singular), + /* translators: %s: post type singular name */ + 'view_item' => sprintf(__('View %s', 'pollora'), $singular), + /* translators: %s: post type general name (plural) */ + 'view_items' => sprintf(__('View %s', 'pollora'), $plural), + /* translators: %s: post type general name (plural) */ + 'search_items' => sprintf(__('Search %s', 'pollora'), $plural), + /* translators: %s: post type general name (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', 'pollora'), $plural), + /* translators: %s: post type singular name */ + 'parent_item_colon' => sprintf(__('Parent %s:', 'pollora'), $singular), + /* translators: %s: post type general name (plural) */ + 'all_items' => sprintf(__('All %s', 'pollora'), $plural), + /* translators: %s: post type singular name */ + 'archives' => sprintf(__('%s Archives', 'pollora'), $singular), + /* translators: %s: post type singular name */ + 'attributes' => sprintf(__('%s Attributes', 'pollora'), $singular), + /* translators: %s: post type singular name */ + 'insert_into_item' => sprintf(__('Insert into %s', 'pollora'), $singular), + /* translators: %s: post type singular name */ + '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 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): array + { + return [ + 'name' => $plural, + 'singular_name' => $singular, + 'menu_name' => $plural, + /* translators: %s: taxonomy general name (plural) */ + 'all_items' => sprintf(__('All %s', 'pollora'), $plural), + /* translators: %s: taxonomy singular name */ + 'edit_item' => sprintf(__('Edit %s', 'pollora'), $singular), + /* translators: %s: taxonomy singular name */ + 'view_item' => sprintf(__('View %s', 'pollora'), $singular), + /* translators: %s: taxonomy singular name */ + 'update_item' => sprintf(__('Update %s', 'pollora'), $singular), + /* translators: %s: taxonomy singular name */ + 'add_new_item' => sprintf(__('Add New %s', 'pollora'), $singular), + /* translators: %s: taxonomy singular name */ + 'new_item_name' => sprintf(__('New %s Name', 'pollora'), $singular), + /* translators: %s: taxonomy general name (plural) */ + 'search_items' => sprintf(__('Search %s', 'pollora'), $plural), + /* translators: %s: taxonomy general name (plural) */ + 'popular_items' => sprintf(__('Popular %s', 'pollora'), $plural), + /* translators: %s: taxonomy general name (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', 'pollora'), $plural), + /* translators: %s: taxonomy general name (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', 'pollora'), $plural), + /* translators: %s: taxonomy singular name */ + 'parent_item' => sprintf(__('Parent %s', 'pollora'), $singular), + /* translators: %s: taxonomy singular name */ + 'parent_item_colon' => sprintf(__('Parent %s:', 'pollora'), $singular), + ]; + } +} 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/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/Providers/PolloraServiceProviderTest.php b/tests/Unit/Providers/PolloraServiceProviderTest.php index 29ecd491..7557d1fa 100644 --- a/tests/Unit/Providers/PolloraServiceProviderTest.php +++ b/tests/Unit/Providers/PolloraServiceProviderTest.php @@ -12,50 +12,20 @@ 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. +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. */ -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); - } -} +beforeEach(function (): void { + unset($GLOBALS['l10n']['pollora']); +}); afterEach(function (): void { - PolloraTextDomainProbe::reset(); + WordPressI18nStandIn::reset(); + unset($GLOBALS['l10n']['pollora']); Mockery::close(); }); @@ -93,8 +63,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 = 'it_IT'; + WordPressI18nStandIn::$loader = (function (string $domain, string $file) use (&$loaded): bool { $loaded[] = [$domain, $file]; return true; @@ -102,13 +72,13 @@ function load_textdomain(string $domain, string $mofile, ?string $locale = null) (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 = []; - PolloraTextDomainProbe::$locale = 'de_DE'; - PolloraTextDomainProbe::$loader = (function (string $domain, string $file) use (&$loaded): bool { + WordPressI18nStandIn::$locale = 'nl_NL'; + WordPressI18nStandIn::$loader = (function (string $domain, string $file) use (&$loaded): bool { $loaded[] = $file; return false; @@ -117,8 +87,8 @@ function load_textdomain(string $domain, string $mofile, ?string $locale = null) (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 { 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..d1cd28d6 --- /dev/null +++ b/tests/Unit/Translation/DefaultLabelsTest.php @@ -0,0 +1,461 @@ + 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('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_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 { + 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', '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(); + + $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('still ask for the names with no catalogue, so that the gettext filters apply to them', function (): void { + i18nWiringCatalogue([]); + $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($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('give the names back unchanged with no catalogue and no filter, after asking for them', function (): void { + i18nWiringCatalogue([]); + $recorder = i18nWiringRecorder(); + + (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')->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 { + 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); + }); +}); + +/** + * @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 still looked up with no catalogue, so that the gettext filters of the site and of the domain apply', function (): void { + i18nWiringCatalogue([]); + $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($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 { + 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']); + WordPressI18nStandIn::reset(); + }); + + afterEach(function (): void { + 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']); + 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(WordPressI18nStandIn::$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([]); + WordPressI18nStandIn::$locale = 'xx_SECOND'; + $GLOBALS['l10n']['pollora'] = new stdClass; + + (new TranslatingEntityRegistry(i18nWiringRecorder()))->register('book', ['labels' => TranslatingEntityRegistry::defaultLabels('post_type', 'Book', 'Books')], []); + + expect(WordPressI18nStandIn::$loads)->toBe([]); + }); +}); diff --git a/tests/Unit/Translation/TranslatedAttributeTextsTest.php b/tests/Unit/Translation/TranslatedAttributeTextsTest.php new file mode 100644 index 00000000..75c40c23 --- /dev/null +++ b/tests/Unit/Translation/TranslatedAttributeTextsTest.php @@ -0,0 +1,348 @@ +>|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('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 new file mode 100644 index 00000000..7cc2d657 --- /dev/null +++ b/tests/Unit/Translation/TranslatingEntityRegistryTest.php @@ -0,0 +1,423 @@ + [], 'words' => []]; + +/** + * @param array $words 'domain|text' or 'domain|context|text' => translation + */ +function i18nWiringCatalogue(array $words): void +{ + $GLOBALS['i18nWiring'] = ['calls' => [], 'words' => $words, 'filters' => []]; + + $lookup = function (string $key, string $text): string { + $GLOBALS['i18nWiring']['calls'][] = $key; + + 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('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 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() + */ +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(); + }); +}); 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); + } +}