diff --git a/.gitignore b/.gitignore index b17f2a924..9a3b4e12d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ assets/js/msls-widget-block/ assets/js/msls.js build/translations.json composer.phar +docs/specs/ multisite-language-switcher.zip multisite-language-switcher/ node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 64209703d..0b2fe6581 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ npm run build-msls-block # Build only the Gutenberg block ### E2E Tests ```bash +npx playwright install chromium # Once per machine: every project launches Chromium npx wp-env start # Required first: the local suite runs against wp-env npm run playwright:local # Admin + frontend specs (skips visual) npm run playwright:visual # Visual specs inside the Playwright Linux image @@ -40,6 +41,9 @@ npm run playwright:update-snapshots # Regenerate visual baselines npm run playwright:live # Only specs/live, against msls.co (no wp-env needed) npx playwright test --ui # Run with UI ``` +Without the browser every spec fails with `browserType.launch: Executable doesn't exist`. +The three container scripts are exempt: the Playwright Linux image ships its own browsers. + Visual baselines are committed and only pixel-stable when generated inside the container — always use the `:visual` / `:update-snapshots` scripts, never a bare `npx playwright test` for them. @@ -80,7 +84,7 @@ it, so activate it once per fresh **development** environment with the command a - PSR-4 (dev): `lloc\MslsTests\` maps to `tests/phpunit/` - Plugin bootstrap: `MultisiteLanguageSwitcher.php` — defines constants, requires `vendor/autoload.php` plus `includes/aliases.php`, `includes/deprecated.php` and `includes/api.php` **at file-load time**, then calls `lloc\Msls\Plugin::init()` and `lloc\Msls\Cli\Cli::init()` on `plugins_loaded`. Do not move those requires into the hook: add-ons may load before us, and they need the aliases and the `msls_*()` functions to exist the moment the plugin file is included - **Backwards-compatibility aliases**: `lloc\Msls\Compat\Aliases::MAP` (`includes/Compat/Aliases.php`) maps the ~60 pre-3.0 flat class names (`MslsOptions`, `MslsLink`, `MslsPlugin`, …) to their namespaced replacements. `::register()` — invoked from the thin `includes/aliases.php` — creates them with `class_alias()` **eagerly**, plus an autoloader for the handful in `::LAZY_ONLY`. Do not make them lazy across the board: PHP resolves the class named in a parameter/return/property type with `ZEND_FETCH_CLASS_NO_AUTOLOAD`, so an alias created on demand never gets its chance and the call fatals with a `TypeError` (MslsMenu declares `get_msls_output(): lloc\Msls\MslsOutput`). `LAZY_ONLY` is limited to names that never shipped before 3.0, so nothing can be holding them. Write new code against the namespaced names; the aliases exist only for third-party consumers -- **PHP-DI**: `lloc\Msls\Container::get()` builds the container from `config.php` on first use and caches it. `config.php` is still empty — nothing is injected through it yet +- **Service container**: `lloc\Msls\Container::get()` builds a `lloc\Msls\ServiceContainer` from `config.php` on first use and caches it for the request. Definitions are closures (called with the container), plain values, or nothing at all, in which case an existing class name is instantiated. `config.php` is still empty, nothing is injected through it yet. This replaced PHP-DI in 3.1.0: the 6.4 series is unmaintained and emits PHP 8.4 deprecations from its autoloaded `functions.php`, while PHP-DI 7 requires PHP 8.0 and the plugin declares PHP 7.4. Do not reintroduce a DI library while that floor stands ### Key Patterns - **Registry/Singleton**: `Registry\Instance` is the base class providing the `::instance()` static accessor (backed by `Registry\Registry`); `Registry\GetSet` extends it to add overloaded property access diff --git a/Changelog.md b/Changelog.md index 1780176bc..da91f33d6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,12 @@ +## 3.1.0 +* Add pagination to the links of the switcher: a visitor on page 3 of an archive is sent to page 3 of the translated archive instead of the first page. That applies to the pages of an archive as well as to the pages of a post which is split by ``. +* Add `Options::get_max_pages()` to the whole options hierarchy, together with the `Options::PAGINATION_ARCHIVE` and `Options::PAGINATION_SINGLE` constants. A page is only added when the other blog has it, so a link which would be out of range still points to the first page. +* Add `msls_preserve_pagination` to switch the feature off, `msls_pagination_max_pages` to correct the number of pages MSLS calculates, and `msls_pagination_get` to adjust the finished link. All three are documented in `docs/hooks.md`. +* Fix: PHP 8.4 raised deprecation notices on every request. They came from the bundled PHP-DI 6.4, which declares its helper functions with implicitly nullable parameters and loads them through the autoloader on every page load. The 6.4 series is unmaintained and PHP-DI 7 requires PHP 8.0, so the library has been replaced by a small container of the plugin's own. +* Fix: three parameters of the content import were declared as implicitly nullable, which PHP 8.4 deprecates as well. +* Internal: `Container::get()` returns a `lloc\Msls\ServiceContainer` instead of a `DI\Container`. It reads the same `config.php` and resolves closures, plain values, and class names. +* Internal: `OptionsInterface` is unchanged, so a custom implementation keeps working and simply gets no page appended. + ## 3.0.3 * Fix: the plugin required PHP-DI 7, which needs PHP 8.0, while MSLS itself declares PHP 7.4 as its minimum. PHP-DI is back on the 6.4 series, so an installation on PHP 7.4 can resolve the dependencies again. * Internal: declare `Requires PHP: 7.4` in the plugin header so WordPress blocks activation on older PHP versions, and check the production dependencies against PHP 7.4 in CI. diff --git a/MultisiteLanguageSwitcher.php b/MultisiteLanguageSwitcher.php index 3f9d93c7d..3b52d1253 100644 --- a/MultisiteLanguageSwitcher.php +++ b/MultisiteLanguageSwitcher.php @@ -3,7 +3,7 @@ * Multisite Language Switcher Plugin * * Plugin Name: Multisite Language Switcher - * Version: 3.0.3 + * Version: 3.1.0 * Plugin URI: http://msls.co/ * Description: A simple but powerful plugin that will help you to manage the relations of your contents in a multilingual multisite-installation. * Requires PHP: 7.4 @@ -43,7 +43,7 @@ * @author Dennis Ploetner */ if ( ! defined( 'MSLS_PLUGIN_VERSION' ) ) { - define( 'MSLS_PLUGIN_VERSION', '3.0.3' ); + define( 'MSLS_PLUGIN_VERSION', '3.1.0' ); define( 'MSLS_PLUGIN_PATH', plugin_basename( __FILE__ ) ); define( 'MSLS_PLUGIN__FILE__', __FILE__ ); diff --git a/assets/css-flags/flags.php b/assets/css-flags/flags.php index 5a0da669c..22382fab2 100644 --- a/assets/css-flags/flags.php +++ b/assets/css-flags/flags.php @@ -3,7 +3,7 @@ /** * File is auto-generated * - * 132 translations-teams for WordPress found + * 133 translations-teams for WordPress found */ // Unhandled language: am (Amharic) // Unhandled language: arg (Aragonese) @@ -13,6 +13,7 @@ // Unhandled language: hsb (Upper Sorbian) // Unhandled language: kn (Kannada) // Unhandled language: kir (Kyrgyz) +// Unhandled language: pcm (Nigerian Pidgin) // Unhandled language: snd (Sindhi) // Unhandled language: sw (Swahili) // Unhandled language: yor (Yoruba) @@ -33,39 +34,39 @@ 'cy' => 'flag-icon-gb-wls', 'da_DK' => 'flag-icon-dk', 'de_AT' => 'flag-icon-at', - 'de_CH_informal' => 'flag-icon-ch', 'de_DE' => 'flag-icon-de', 'de_DE_formal' => 'flag-icon-de', 'de_CH' => 'flag-icon-ch', + 'de_CH_informal' => 'flag-icon-ch', 'dzo' => 'flag-icon-bt', 'el' => 'flag-icon-gr', - 'en_AU' => 'flag-icon-au', + 'en_CA' => 'flag-icon-ca', + 'en_NZ' => 'flag-icon-nz', 'en_GB' => 'flag-icon-gb', 'en_ZA' => 'flag-icon-za', - 'en_NZ' => 'flag-icon-nz', - 'en_CA' => 'flag-icon-ca', + 'en_AU' => 'flag-icon-au', 'eo' => 'flag-icon-eu', 'es_AR' => 'flag-icon-ar', - 'es_CR' => 'flag-icon-cr', + 'es_CL' => 'flag-icon-cl', + 'es_ES' => 'flag-icon-es', 'es_MX' => 'flag-icon-mx', - 'es_CO' => 'flag-icon-co', 'es_PE' => 'flag-icon-pe', 'es_VE' => 'flag-icon-ve', 'es_EC' => 'flag-icon-ec', 'es_DO' => 'flag-icon-do', 'es_UY' => 'flag-icon-uy', 'es_PR' => 'flag-icon-pr', - 'es_CL' => 'flag-icon-cl', 'es_GT' => 'flag-icon-gt', - 'es_ES' => 'flag-icon-es', + 'es_CR' => 'flag-icon-cr', + 'es_CO' => 'flag-icon-co', 'et' => 'flag-icon-ee', 'eu' => 'flag-icon-es', 'fa_IR' => 'flag-icon-ir', 'fa_AF' => 'flag-icon-af', 'fi' => 'flag-icon-fi', 'fr_CA' => 'flag-icon-ca', - 'fr_FR' => 'flag-icon-fr', 'fr_BE' => 'flag-icon-be', + 'fr_FR' => 'flag-icon-fr', 'fur' => 'flag-icon-it', 'gd' => 'flag-icon-gb-sct', 'gl_ES' => 'flag-icon-es', @@ -98,18 +99,18 @@ 'my_MM' => 'flag-icon-mm', 'nb_NO' => 'flag-icon-no', 'ne_NP' => 'flag-icon-np', - 'nl_BE' => 'flag-icon-be', 'nl_NL' => 'flag-icon-nl', 'nl_NL_formal' => 'flag-icon-nl', + 'nl_BE' => 'flag-icon-be', 'nn_NO' => 'flag-icon-no', 'oci' => 'flag-icon-es-ca', 'pa_IN' => 'flag-icon-in', 'pl_PL' => 'flag-icon-pl', 'ps' => 'flag-icon-af', 'pt_BR' => 'flag-icon-br', - 'pt_AO' => 'flag-icon-ao', 'pt_PT' => 'flag-icon-pt', 'pt_PT_ao90' => 'flag-icon-90', + 'pt_AO' => 'flag-icon-ao', 'ro_RO' => 'flag-icon-ro', 'ru_RU' => 'flag-icon-ru', 'sah' => 'flag-icon-ru', diff --git a/assets/flags/flags.php b/assets/flags/flags.php index fa58c36cc..3e8a7ff78 100644 --- a/assets/flags/flags.php +++ b/assets/flags/flags.php @@ -3,7 +3,7 @@ /** * File is auto-generated * - * 132 translations-teams for WordPress found + * 133 translations-teams for WordPress found */ // Unhandled language: am (Amharic) // Unhandled language: arg (Aragonese) @@ -12,6 +12,7 @@ // Unhandled language: hsb (Upper Sorbian) // Unhandled language: kn (Kannada) // Unhandled language: kir (Kyrgyz) +// Unhandled language: pcm (Nigerian Pidgin) // Unhandled language: snd (Sindhi) // Unhandled language: sw (Swahili) // Unhandled language: yor (Yoruba) @@ -33,39 +34,39 @@ 'cy' => 'wales.png', 'da_DK' => 'dk.png', 'de_AT' => 'at.png', - 'de_CH_informal' => 'ch.png', 'de_DE' => 'de.png', 'de_DE_formal' => 'de.png', 'de_CH' => 'ch.png', + 'de_CH_informal' => 'ch.png', 'dzo' => 'bt.png', 'el' => 'gr.png', - 'en_AU' => 'au.png', + 'en_CA' => 'ca.png', + 'en_NZ' => 'nz.png', 'en_GB' => 'gb.png', 'en_ZA' => 'za.png', - 'en_NZ' => 'nz.png', - 'en_CA' => 'ca.png', + 'en_AU' => 'au.png', 'eo' => 'europeanunion.png', 'es_AR' => 'ar.png', - 'es_CR' => 'cr.png', + 'es_CL' => 'cl.png', + 'es_ES' => 'es.png', 'es_MX' => 'mx.png', - 'es_CO' => 'co.png', 'es_PE' => 'pe.png', 'es_VE' => 've.png', 'es_EC' => 'ec.png', 'es_DO' => 'do.png', 'es_UY' => 'uy.png', 'es_PR' => 'pr.png', - 'es_CL' => 'cl.png', 'es_GT' => 'gt.png', - 'es_ES' => 'es.png', + 'es_CR' => 'cr.png', + 'es_CO' => 'co.png', 'et' => 'ee.png', 'eu' => 'es.png', 'fa_IR' => 'ir.png', 'fa_AF' => 'af.png', 'fi' => 'fi.png', 'fr_CA' => 'ca.png', - 'fr_FR' => 'fr.png', 'fr_BE' => 'be.png', + 'fr_FR' => 'fr.png', 'fur' => 'it.png', 'gd' => 'scotland.png', 'gl_ES' => 'es.png', @@ -98,18 +99,18 @@ 'my_MM' => 'mm.png', 'nb_NO' => 'no.png', 'ne_NP' => 'np.png', - 'nl_BE' => 'be.png', 'nl_NL' => 'nl.png', 'nl_NL_formal' => 'nl.png', + 'nl_BE' => 'be.png', 'nn_NO' => 'no.png', 'oci' => 'catalonia.png', 'pa_IN' => 'in.png', 'pl_PL' => 'pl.png', 'ps' => 'af.png', 'pt_BR' => 'br.png', - 'pt_AO' => 'ao.png', 'pt_PT' => 'pt.png', 'pt_PT_ao90' => '90.png', + 'pt_AO' => 'ao.png', 'ro_RO' => 'ro.png', 'ru_RU' => 'ru.png', 'sah' => 'ru.png', diff --git a/composer.json b/composer.json index de546b968..03c822d58 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,6 @@ "require": { "php": ">=7.4", "composer/installers": "~2.3.0", - "php-di/php-di": "^6.4", "ext-json": "*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 08012b170..3f698a0f5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "75744df76ef89bcde4eec6f0c7860707", + "content-hash": "b416f983875cff66d75d62d2e4a4fb3e", "packages": [ { "name": "composer/installers", @@ -151,288 +151,6 @@ } ], "time": "2024-06-24T20:46:46+00:00" - }, - { - "name": "laravel/serializable-closure", - "version": "v1.3.7", - "source": { - "type": "git", - "url": "https://github.com/laravel/serializable-closure.git", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d", - "shasum": "" - }, - "require": { - "php": "^7.3|^8.0" - }, - "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\SerializableClosure\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - }, - { - "name": "Nuno Maduro", - "email": "nuno@laravel.com" - } - ], - "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", - "keywords": [ - "closure", - "laravel", - "serializable" - ], - "support": { - "issues": "https://github.com/laravel/serializable-closure/issues", - "source": "https://github.com/laravel/serializable-closure" - }, - "time": "2024-11-14T18:34:49+00:00" - }, - { - "name": "php-di/invoker", - "version": "2.3.7", - "source": { - "type": "git", - "url": "https://github.com/PHP-DI/Invoker.git", - "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", - "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", - "shasum": "" - }, - "require": { - "php": ">=7.3", - "psr/container": "^1.0|^2.0" - }, - "require-dev": { - "athletic/athletic": "~0.1.8", - "mnapoli/hard-mode": "~0.3.0", - "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" - }, - "type": "library", - "autoload": { - "psr-4": { - "Invoker\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Generic and extensible callable invoker", - "homepage": "https://github.com/PHP-DI/Invoker", - "keywords": [ - "callable", - "dependency", - "dependency-injection", - "injection", - "invoke", - "invoker" - ], - "support": { - "issues": "https://github.com/PHP-DI/Invoker/issues", - "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - } - ], - "time": "2025-08-30T10:22:22+00:00" - }, - { - "name": "php-di/php-di", - "version": "6.4.0", - "source": { - "type": "git", - "url": "https://github.com/PHP-DI/PHP-DI.git", - "reference": "ae0f1b3b03d8b29dff81747063cbfd6276246cc4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/ae0f1b3b03d8b29dff81747063cbfd6276246cc4", - "reference": "ae0f1b3b03d8b29dff81747063cbfd6276246cc4", - "shasum": "" - }, - "require": { - "laravel/serializable-closure": "^1.0", - "php": ">=7.4.0", - "php-di/invoker": "^2.0", - "php-di/phpdoc-reader": "^2.0.1", - "psr/container": "^1.0" - }, - "provide": { - "psr/container-implementation": "^1.0" - }, - "require-dev": { - "doctrine/annotations": "~1.10", - "friendsofphp/php-cs-fixer": "^2.4", - "mnapoli/phpunit-easymock": "^1.2", - "ocramius/proxy-manager": "^2.11.2", - "phpstan/phpstan": "^0.12", - "phpunit/phpunit": "^9.5" - }, - "suggest": { - "doctrine/annotations": "Install it if you want to use annotations (version ~1.2)", - "ocramius/proxy-manager": "Install it if you want to use lazy injection (version ~2.0)" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "DI\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "The dependency injection container for humans", - "homepage": "https://php-di.org/", - "keywords": [ - "PSR-11", - "container", - "container-interop", - "dependency injection", - "di", - "ioc", - "psr11" - ], - "support": { - "issues": "https://github.com/PHP-DI/PHP-DI/issues", - "source": "https://github.com/PHP-DI/PHP-DI/tree/6.4.0" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", - "type": "tidelift" - } - ], - "time": "2022-04-09T16:46:38+00:00" - }, - { - "name": "php-di/phpdoc-reader", - "version": "2.2.1", - "source": { - "type": "git", - "url": "https://github.com/PHP-DI/PhpDocReader.git", - "reference": "66daff34cbd2627740ffec9469ffbac9f8c8185c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/PhpDocReader/zipball/66daff34cbd2627740ffec9469ffbac9f8c8185c", - "reference": "66daff34cbd2627740ffec9469ffbac9f8c8185c", - "shasum": "" - }, - "require": { - "php": ">=7.2.0" - }, - "require-dev": { - "mnapoli/hard-mode": "~0.3.0", - "phpunit/phpunit": "^8.5|^9.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "PhpDocReader\\": "src/PhpDocReader" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PhpDocReader parses @var and @param values in PHP docblocks (supports namespaced class names with the same resolution rules as PHP)", - "keywords": [ - "phpdoc", - "reflection" - ], - "support": { - "issues": "https://github.com/PHP-DI/PhpDocReader/issues", - "source": "https://github.com/PHP-DI/PhpDocReader/tree/2.2.1" - }, - "time": "2020-10-12T12:39:22+00:00" - }, - { - "name": "psr/container", - "version": "1.1.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" - }, - "time": "2021-11-05T16:50:12+00:00" } ], "packages-dev": [ @@ -849,16 +567,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.8.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { @@ -900,9 +618,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "phar-io/manifest", @@ -1024,16 +742,16 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.9.4", + "version": "v7.1.0", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5" + "reference": "394570301894799e69a4aa117c9c995427e3ba04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5", - "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/394570301894799e69a4aa117c9c995427e3ba04", + "reference": "394570301894799e69a4aa117c9c995427e3ba04", "shasum": "" }, "conflict": { @@ -1043,13 +761,13 @@ "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "nikic/php-parser": "^5.5", "php": "^7.4 || ^8.0", - "php-stubs/generator": "^0.8.6", + "php-stubs/generator": "^0.9", "phpdocumentor/reflection-docblock": "^6.0", - "phpstan/phpstan": "^2.1", + "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^9.5", "symfony/polyfill-php80": "*", - "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", - "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.2", + "wp-coding-standards/wpcs": "3.4.1 as 2.3.0" }, "suggest": { "paragonie/sodium_compat": "Pure PHP implementation of libsodium", @@ -1070,9 +788,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v7.1.0" }, - "time": "2026-05-01T20:36:01+00:00" + "time": "2026-08-28T00:32:06+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -1361,16 +1079,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "shasum": "" }, "require": { @@ -1402,17 +1120,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" }, - "time": "2026-07-08T07:01:06+00:00" + "time": "2026-08-31T16:05:28+00:00" }, { "name": "phpstan/phpstan", - "version": "2.2.9", + "version": "2.2.14", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", - "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "shasum": "" }, "require": { @@ -1468,7 +1186,7 @@ "type": "github" } ], - "time": "2026-08-22T07:38:16+00:00" + "time": "2026-09-12T21:39:33+00:00" }, { "name": "phpstan/phpstan-mockery", @@ -3023,16 +2741,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.17", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", - "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe", + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe", "shasum": "" }, "require": { @@ -3067,7 +2785,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.17" + "source": "https://github.com/symfony/finder/tree/v7.4.19" }, "funding": [ { @@ -3087,25 +2805,25 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-09-10T19:14:10+00:00" }, { "name": "szepeviktor/phpstan-wordpress", - "version": "v2.0.3", + "version": "v2.0.4", "source": { "type": "git", "url": "https://github.com/szepeviktor/phpstan-wordpress.git", - "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e" + "reference": "84577e2ea1e4c2a95537fe5ea2ac8e18f4ceab1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/aa722f037b2d034828cd6c55ebe9e5c74961927e", - "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e", + "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/84577e2ea1e4c2a95537fe5ea2ac8e18f4ceab1a", + "reference": "84577e2ea1e4c2a95537fe5ea2ac8e18f4ceab1a", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "php-stubs/wordpress-stubs": "^6.6.2", + "php-stubs/wordpress-stubs": ">=6.6.2", "phpstan/phpstan": "^2.0" }, "require-dev": { @@ -3148,9 +2866,9 @@ ], "support": { "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", - "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.3" + "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.4" }, - "time": "2025-09-14T02:58:22+00:00" + "time": "2026-05-22T16:22:09+00:00" }, { "name": "theseer/tokenizer", diff --git a/docs/api.md b/docs/api.md index 48089b152..047556405 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3,7 +3,7 @@ Multisite Language Switcher ships with a small set of global helper functions that act as the plugin's public API. They live in `includes/api.php`, which `MultisiteLanguageSwitcher.php` requires the moment the plugin file is -included — before `plugins_loaded` fires. They are therefore available to +included, before `plugins_loaded` fires. They are therefore available to add-ons and themes regardless of the order in which WordPress loads the plugins. @@ -13,8 +13,8 @@ reach into MSLS without instantiating its internal classes directly. They also wrap MSLS's registry/singleton plumbing, which means calling them repeatedly is cheap and safe. -A `function_exists()` guard is recommended before any of these calls in theme -templates, so your theme degrades gracefully when MSLS is deactivated: +Guard these calls with `function_exists()` in theme templates, so the theme +still renders when MSLS is deactivated: ```php if ( function_exists( 'msls_the_switcher' ) ) { @@ -45,7 +45,7 @@ your own escaping/filtering. The optional argument behaves the same as for Returns the URL of the translation of the current post (or term, or archive) in the language identified by the given locale. If no translation exists, the -optional `$preset` string is returned instead — typically an empty string or +optional `$preset` string is returned instead, typically an empty string or a fallback URL. Useful when you need to render your own switcher markup or build a single direct link to a specific language. @@ -66,7 +66,7 @@ optional `$preset` is returned when no matching blog is registered. Resolves a locale to its `Blog` instance, or `null` when no blog with that locale is part of the MSLS collection. Use it when you need direct access to -a single blog — for example to call `get_url()` for a custom switcher — and +a single blog (for example to call `get_url()` for a custom switcher) and want to handle the missing-blog case explicitly. ## Service accessors @@ -79,7 +79,7 @@ it when you want to iterate over the full set of languages yourself. ### msls_options -Returns the global `Options` singleton — the merged plugin settings for the +Returns the global `Options` singleton, the merged plugin settings for the current request. Use it to read configuration values such as the display mode, image URL overrides, or whether MSLS should include the current blog in the switcher. @@ -94,9 +94,9 @@ or inspect the resolved switcher state before rendering. ### msls_content_types Returns the `ContentTypes\ContentTypes` instance, a context-aware factory -that exposes both supported post types and taxonomies. Use it when you need -to ask "is this current request a translatable content type?" without -worrying about whether you're on a post or a taxonomy screen. +that exposes both supported post types and taxonomies. Use it to check +whether the current request is a translatable content type without branching +on post and taxonomy screens yourself. ### msls_post_type @@ -115,9 +115,8 @@ taxonomies from outside the plugin. ### msls_get_post Returns the `Options\Post\Post` instance for a specific post ID. The object -exposes the post's translation map — the IDs of the equivalent posts on -other blogs — and helpers such as `get_permalink()` for individual -languages. +exposes the post's translation map (the IDs of the equivalent posts on other +blogs) and helpers such as `get_permalink()` for individual languages. ### msls_get_tax @@ -140,32 +139,31 @@ you might need to query in more detail. A trivial no-op function. It exists so that callers (typically WordPress action registrations or fluent setups) can pass a callable that "does -nothing" without inventing a closure. There's no end-user use case here — -mentioned only for completeness. +nothing" without inventing a closure. There's no end-user use case; it is +listed here only for completeness. ## Deprecated functions The following pre-2.10.1 names live in `includes/deprecated.php`. Each one -still works but emits a `_deprecated_function()` notice and simply forwards -to its modern `msls_*` replacement. Update calls in your code at your -earliest convenience. +still works but emits a `_deprecated_function()` notice and forwards to its +modern `msls_*` replacement. ### get_the_msls -Deprecated since 2.10.1 — use `msls_get_switcher()`. +Deprecated since 2.10.1. Use `msls_get_switcher()`. ### the_msls -Deprecated since 2.10.1 — use `msls_the_switcher()`. +Deprecated since 2.10.1. Use `msls_the_switcher()`. ### get_msls_flag_url -Deprecated since 2.10.1 — use `msls_get_flag_url()`. +Deprecated since 2.10.1. Use `msls_get_flag_url()`. ### get_msls_blog_description -Deprecated since 2.10.1 — use `msls_get_blog_description()`. +Deprecated since 2.10.1. Use `msls_get_blog_description()`. ### get_msls_permalink -Deprecated since 2.10.1 — use `msls_get_permalink()`. +Deprecated since 2.10.1. Use `msls_get_permalink()`. diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 32f483783..a4a362982 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -1,8 +1,8 @@ # End-to-End Testing The plugin ships a [Playwright](https://playwright.dev/) suite in `tests/playwright/`. -`playwright.config.ts` defines two projects, and the project you pick decides both *which* -specs run and *what they run against*: +`playwright.config.ts` defines two projects, and the project you pick decides both which +specs run and what they run against: | Project | Specs | Target | | --- | --- | --- | @@ -15,11 +15,49 @@ The two are deliberately disjoint: `local` sets `testIgnore: ['**/specs/live/**' ## Local suite -The local suite seeds its own multisite topology, so `wp-env` has to be running first. The -commands are listed in [CLAUDE.md](../CLAUDE.md) under *E2E Tests* — in short, -`npm run playwright:local` for the admin and frontend specs, and the `:visual` / -`:update-snapshots` scripts for the visual specs, which are only pixel-stable inside the -Playwright Linux container. +### Running it + +Both projects drive Chromium, so the browser has to be downloaded once per machine. A +checkout does not bring it along, and a run without it fails every spec with +`browserType.launch: Executable doesn't exist at .../chrome-headless-shell`. + +```bash +npx playwright install chromium # once per machine +npx wp-env start # the suite seeds and queries this installation + +npm run playwright:local # admin and frontend specs +``` + +`npm run playwright:install` is the heavier alternative: it runs +`npx playwright install --with-deps`, which pulls every browser plus the OS packages they +need, and wants root on Linux. The suite only ever launches Chromium. + +The container scripts need none of that. `playwright:visual`, `playwright:docker` and +`playwright:update-snapshots` run inside the Playwright Linux image, which ships its own +browsers. They also set `MSLS_SKIP_E2E_SEED=1` and reach `wp-env` on the host, which means +they seed nothing themselves: run `npm run playwright:local` once first, or the specs read +a stale `artifacts/seed.json` and look for posts which are not there. + +### What the seeding creates + +`globalSetup` builds three subsites (root, `de`, `it`), gives each one `/%postname%/` +permalinks and `posts_per_page` of 1, and then creates two translation-linked post sets +per blog: + +| Set | Slugs | Content | +| --- | --- | --- | +| `seed.posts` | `msls-demo-{en,de,it}` | one page, `[sc_msls]` in the body | +| `seed.paged` | `msls-paged-{en,de,it}` | root and `de` are split by ``, `it` is not | + +`posts_per_page` of 1 is what makes the blog index paginated without seeding eleven posts +per blog through wp-cli, and the asymmetric `msls-paged` set is what lets the pagination +specs assert both a link which keeps its page and one which runs out of range on the +target blog. Both sets are linked through the `msls_` options, and both are +deleted and rebuilt on every run. + +The visual specs are only pixel-stable inside the Playwright Linux container, so they are +reached through the `:visual` / `:update-snapshots` scripts. `playwright:local` filters +them out with `--grep-invert=visual`. ## Live suite @@ -38,22 +76,22 @@ npm run playwright:live # against ms MSLS_LIVE_URL=https://staging.example.com npm run playwright:live # against another host ``` -There is no `.env` support in this repository — nothing loads env files, so `MSLS_LIVE_URL` -has to be exported in your shell or prefixed to the command. (And note that `.gitignore` -currently has no `.env` entry, so a file you create there would *not* be ignored.) +There is no `.env` support in this repository: nothing loads env files, so `MSLS_LIVE_URL` +has to be exported in your shell or prefixed to the command. (`.gitignore` currently has +no `.env` entry either, so a file you create there would not be ignored.) ### What the target installation has to provide The assertions in `tests/playwright/specs/live/testpage.spec.ts` are specific. The target needs: -* a publicly reachable page at `/testpage` — no login wall +* a publicly reachable page at `/testpage`, with no login wall * a network offering the languages `de_DE` and `en_GB` * a `.widget_mslswidget` container holding the links *de_DE Deutsch* and *en_GB English* -* an `.msls-menu` holding the links *de_DE* and *en_GB* (exact text) — **msls.co does not - currently provide this**, see *Current status* below +* an `.msls-menu` holding the links *de_DE* and *en_GB* (exact text); msls.co does not + currently provide this, see *Current status* below * at least three switcher renderings inside `.entry-content` with *de_DE Deutsch* / - *en_GB English* — the spec iterates `nth(0)` through `nth(2)` + *en_GB English*; the spec iterates `nth(0)` through `nth(2)` * additional links inside `.entry-content` reading exactly *Deutsch* / *English*, for the translation-hint test * the `current_language` class on whichever link is currently active @@ -63,12 +101,12 @@ msls.co will break the suite. ### Current status -As of 2026-08-22 the suite is **5 passed, 1 failed** against msls.co. The failing test is +As of 2026-08-22 the suite is 5 passed, 1 failed against msls.co. The failing test is `testing with .msls-menu de_DE en_GB`: `/testpage` does not render an element with the `msls-menu` class, while the site's Custom CSS rule (`.msls-menu a { display: inline-block; }`) is still there. -That is **not** fixture drift — it is the plugin. Between commit `3afd781` and the 3.0.0 +The plugin is at fault here, not the fixture. Between commit `3afd781` and the 3.0.0 release the backwards-compatibility aliases were loaded inside a `plugins_loaded` callback, which made `class_exists( 'lloc\Msls\MslsOptions' )` return `false` for the MslsMenu add-on, so MslsMenu registered neither its `wp_nav_menu_items` filter nor its settings section. @@ -76,9 +114,9 @@ so MslsMenu registered neither its `wp_nav_menu_items` filter nor its settings s The cause is fixed on this branch (commit `7e80283`, PR #690): `includes/aliases.php`, `includes/deprecated.php` and `includes/api.php` are required at file-load time again. The spec keeps failing against msls.co only because the site still runs the released 2.10.1 -code. **Re-run it once 3.0.0 is deployed to msls.co — it is the release verification for -the add-on connector fix, and it should then be 6 passed.** Keep the spec either way: it is -the standing regression test for that bug. +code. Re-run it once 3.0.0 is deployed to msls.co: it is the release verification for the +add-on connector fix, and it should then be 6 passed. Keep the spec either way, since it +is the standing regression test for that bug. ### Pitfalls @@ -87,19 +125,19 @@ the standing regression test for that bug. live tests themselves pass either way, but the setup runs first and has side effects that have nothing to do with the run: -* it re-seeds your local test environment — `seedTranslationLinkedPosts()` calls +* it re-seeds your local test environment: `deleteSeededPosts()` calls `wp post delete --force` for every `post_type=post` entry on all three subsites before - recreating the demo posts (`global-setup.ts:193-199`), so local posts are gone + the post sets are created again (`global-setup.ts:189-203`), so local posts are gone * it re-primes the admin storage states and rewrites `tests/playwright/artifacts/seed.json` * it adds roughly half a minute of `npx wp-env run tests-cli` round-trips * with `wp-env` stopped it fails outright, since every step shells out to that container `npm run playwright:live` sets `MSLS_LIVE_ONLY=1`, which makes the setup return before any -of that happens (`tests/playwright/setup/global-setup.ts:259`). +of that happens (`tests/playwright/setup/global-setup.ts:303`). **Never run the suite bare.** A plain `npm run playwright` or `npx playwright test` -executes *both* projects — so it hits msls.co with the live specs on top of seeding your +executes both projects, so it hits msls.co with the live specs on top of seeding your local environment. Use `playwright:local` while working locally. **Docker is not an option here.** `tests/playwright/scripts/run-in-docker.sh` exists for @@ -141,15 +179,15 @@ verify a release against the real site. issues a writing request against production. * **Known issue:** the `testing translation hint` test clicks a link named *English* right after asserting `toHaveCount(0)` for that same name. The test currently passes, but the - intent is muddled — don't mistake a later fix for a regression. + intent is muddled, so don't mistake a later fix for a regression. ## Environment variables | Variable | Default | Effect | Read at | | --- | --- | --- | --- | | `MSLS_LIVE_URL` | `https://msls.co` | `baseURL` of the `live` project | `playwright.config.ts:6` | -| `MSLS_LIVE_ONLY` | unset | `1` skips all seeding and auth in `globalSetup` | `global-setup.ts:259` | +| `MSLS_LIVE_ONLY` | unset | `1` skips all seeding and auth in `globalSetup` | `global-setup.ts:303` | | `WP_BASE_URL` | `http://localhost:8889` | `baseURL` of the `local` project, and the host `globalSetup` seeds | `playwright.config.ts:5`, `global-setup.ts:7`, `msls-fixtures.ts:12` | -| `MSLS_SKIP_E2E_SEED` | unset | `1` skips seeding but keeps the local target — set by `run-in-docker.sh` | `global-setup.ts:255` | +| `MSLS_SKIP_E2E_SEED` | unset | `1` skips seeding but keeps the local target (set by `run-in-docker.sh`) | `global-setup.ts:299` | | `STORAGE_STATE_DIR` | `tests/playwright/artifacts/storage-states` | where admin storage states are written and read | `global-setup.ts:10`, `msls-fixtures.ts:14` | | `CI` | unset | enables `forbidOnly`, `retries: 2`, `workers: 1` | `playwright.config.ts:4` | diff --git a/docs/hooks.md b/docs/hooks.md index 4711dfd3e..3ef7a7ba7 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -2,19 +2,20 @@ Multisite Language Switcher exposes a number of WordPress actions and filters that let developers customize the plugin's behavior without modifying its -source. Almost every hook name is prefixed with `msls_`, which makes them easy -to find in the codebase with a quick `grep -rn "msls_" includes/`. +source. Almost every hook name is prefixed with `msls_`, so `grep -rn "msls_" +includes/` finds nearly all of them. The one extension point it misses is +`wp_admin_bar_show_site_icons`, which reuses a WordPress core name and is +documented under "Blogs and collection" below. This reference lists every extension point the plugin emits, grouped by -subsystem. For each hook you'll find a short heading with the literal hook -name and a paragraph explaining what it does and a typical use case. To learn -the exact arguments a hook receives, grep for the hook name in `includes/` — -the `apply_filters()` / `do_action()` call site is the source of truth. +subsystem. For the exact arguments a hook receives, grep for its name in +`includes/`: the `apply_filters()` / `do_action()` call site is the source of +truth. ## What add-ons can rely on Everything an add-on needs is in place from the moment -`MultisiteLanguageSwitcher.php` is included — before `plugins_loaded` fires, and +`MultisiteLanguageSwitcher.php` is included, before `plugins_loaded` fires, and therefore regardless of the order in which WordPress happens to load the two plugins: @@ -26,7 +27,7 @@ plugins: The 3.0 restructuring moved these into sub-namespaces; `lloc\Msls\Compat\Aliases` registers them as `class_alias()` entries, so `class_exists( 'lloc\Msls\MslsOptions' )` keeps working and the old names are equally valid in type declarations. Write - new code against the namespaced names — the aliases exist for third-party + new code against the namespaced names; the aliases exist for third-party consumers. The hooks below, by contrast, fire during `plugins_loaded` and later. @@ -45,10 +46,10 @@ switcher renderer for the entire site or per template. Filter that builds the markup for every individual language item before it joins the output array. It receives the target URL (not the finished anchor), -the `LinkInterface` object, and whether the item points at the current blog — +the `LinkInterface` object, and whether the item points at the current blog, so the return value has to be the complete HTML for that item. When no callback is attached, MSLS renders its own default anchor instead. Use it to -wrap, decorate, or replace the per-language link — for example to add a CSS +wrap, decorate, or replace the per-language link, for example to add a CSS class, swap in a button element, or append a flag image only on the current language. @@ -96,7 +97,7 @@ language. Override the hook to enforce your own locale-to-hreflang policy. Filter on the "This post is also available in …" hint that the plugin can inject above or below the post content. Return your own translated/branded -template string — the existing list of language links is passed alongside so +template string; the existing list of language links is passed alongside so your replacement can still reference them. ### msls_widget_title @@ -115,11 +116,11 @@ found"; override it to localize differently or render a custom call-to-action. ### msls_link_create -Filter that fires inside the `Link` factory when a switcher item is being -materialized into one of the built-in link variants (text, image, image plus -text, etc.). Return a different object implementing `LinkInterface` to -substitute a fully custom link renderer — for example one that emits a button -component or an icon-only SVG. +Filter that fires inside the `Link` factory when a switcher item is turned +into one of the built-in link variants (text, image, image plus text, etc.). +Return a different object implementing `LinkInterface` to substitute a fully +custom link renderer, for example one that emits a button component or an +icon-only SVG. ## Permalinks and options @@ -130,6 +131,13 @@ asks an `Options` object (post, term, or archive query) for the URL it should point to in another language. Override it to rewrite translation URLs, for example to route through a marketing redirector or to add tracking parameters. +Its predecessor `check_url` still fires immediately before it, through +`apply_filters_deprecated()`, and has done since 2.7.1. Callbacks on the old +name keep working and their return value is handed on to `msls_get_postlink`, +but every invocation raises a deprecation notice. Moving them over needs no +other change: both hooks receive the permalink and the `Options` object, in +that order. + ### msls_options_get_permalink Filter on the permalink produced for a specific language by the lower-level @@ -145,8 +153,8 @@ the bundled `flag-icon/` directory. ### msls_options_get_flag_icon -Filter on the actual filename used for a flag icon for a given language. Use -it when you need to swap individual icons — for example to map a custom +Filter on the filename used for a flag icon for a given language. Use +it when you need to swap individual icons, for example to map a custom locale to a non-standard flag file. ### msls_options_get_available_languages @@ -156,6 +164,91 @@ considers selectable in its admin UI. Useful when you want to extend the WordPress core language list with custom locales or hide locales that should never appear in the per-blog dropdown. +## Pagination + +The switcher keeps the page of a paginated request in its links: a visitor on +`/dania-glowne/page/3/` is sent to `/en/main-dishes/page/3/` instead of the +first page of the archive. That applies to the pages of an archive (the `paged` +query var) as well as to the pages of a post which is split by `` +(the `page` query var). + +A page is only added when the other blog has it. When the page is out of range +the link points to the first page, as it did before 3.1. A blog without a +translation has no page either, so its link stays the home URL it was before. + +`Options::get_max_pages()` decides that. Each options class answers it from the +cheapest source it has: the term count for a taxonomy archive, `wp_count_posts()` +for a post type archive, the count `has_value()` already fetched for a date or +author archive, and the number of `` quicktags for a post or a +static front page. Two cases need a counting query instead: a hierarchical term, +whose own count leaves out the posts of its children, and a search, which has no +stored count at all. The counts are divided by the `posts_per_page` option, so a +blog which narrows or widens an archive in `pre_get_posts` has to correct the +result through `msls_pagination_max_pages`. + +The quicktags are counted the way `WP_Query` splits the content, so a +`` at the very beginning does not open a page of its own. + +The page becomes a path segment only when the link is a pretty permalink. A +link which routes through its query string, such as the archive of a post type +registered with `'rewrite' => false`, gets `paged` or `page` as a query +argument instead. + +The method runs while the blog it counts for is switched in, so the counts and +the permalink settings belong to the target blog while the conditional tags still +describe the request the visitor made. That includes the trailing slash, which +MSLS reads from the `permalink_structure` of the target blog rather than from +`$wp_rewrite`. The pagination base is the exception: `switch_to_blog()` does not +switch `WP_Rewrite`, so the base always comes from the blog which serves the +request. Use `msls_pagination_get` when a blog of the network translates it. + +### msls_preserve_pagination + +Filter on whether the page of the current request is kept at all. Return `false` +to restore the pre-3.1 behavior and always link to the first page. + +```php +add_filter( 'msls_preserve_pagination', '__return_false' ); +``` + +### msls_pagination_max_pages + +Filter on the number of pages MSLS believes the blog has for the current +request. Receives the calculated number, the `Options` object, the language of +the blog, and the context (`paged` for an archive, `single_paged` for a post +split by ``). Return a higher or lower number when your archives +do not use the `posts_per_page` option. + +```php +add_filter( + 'msls_pagination_max_pages', + function ( int $max, $options, string $language, string $context ): int { + return 'paged' === $context && is_post_type_archive( 'recipe' ) + ? (int) ceil( wp_count_posts( 'recipe' )->publish / 24 ) + : $max; + }, + 10, + 4 +); +``` + +### msls_pagination_get + +Filter on the finished link after the page has been added. Receives the URL, the +page, the `Options` object, and the language of the blog. Use it when a blog of +the network translates the pagination base. + +```php +add_filter( + 'msls_pagination_get', + function ( string $url, int $page, $options, string $language ): string { + return 'de_DE' === $language ? str_replace( '/page/', '/seite/', $url ) : $url; + }, + 10, + 4 +); +``` + ## Blogs and collection ### msls_blog_collection_construct @@ -174,7 +267,7 @@ the language switcher instead of relying on the configured description. Mind the argument order, which is the reverse of what the hook name suggests: the **filtered value is the blog ID**, and the description MSLS resolved for that blog arrives as the second argument. Whatever you return is used as the -description — and returning `false` drops the blog from the collection +description, and returning `false` drops the blog from the collection entirely, which is the supported way to hide a site from every consumer at construction time. @@ -192,9 +285,9 @@ add_filter( ### msls_blog_collection_get_blog Filter on the `Blog` instance returned when callers look up a blog by its -language. Returning your own object — or returning a different blog than the -one MSLS would pick — lets you redirect language resolution, for example to -implement a fallback chain when an exact locale match is missing. +language. Returning your own object, or a different blog than the one MSLS +would pick, lets you redirect language resolution, for example to implement a +fallback chain when an exact locale match is missing. ### msls_blog_collection_get_blog_id @@ -219,9 +312,9 @@ without affecting admin features that use the full collection. ### msls_get_users Filter on the arguments passed to `get_users()` when MSLS builds its -"reference user" dropdown. Use it to restrict the candidate users — for -example by role, by capability, or by custom meta — so editors only see the -intended reference accounts. +"reference user" dropdown. Use it to restrict the candidate users by role, +by capability, or by custom meta, so editors only see the intended reference +accounts. MSLS asks for one user more than it displays, because that extra row is how it detects a truncated list without counting every user of the blog. Filtering @@ -238,6 +331,25 @@ translatable context (post, term, or archive). Use it to override the translation URL on a per-blog basis after MSLS has decided which translated object to link to. +### wp_admin_bar_show_site_icons + +The one extension point that does not carry the `msls_` prefix. +`Blog::get_blavatar()` asks WordPress core's own toolbar filter whether it may +render a blog's site icon, and falls back to an empty `
` +when the answer is no. Reusing the core name is deliberate: a site that hides +site icons in the toolbar almost certainly wants them hidden in a blog list +too, so one filter covers both. + +The consequence worth knowing is that this works in the other direction as +well. Filtering the core hook to `false` for the toolbar also empties the +blavatars MSLS renders, and there is no separate `msls_` switch that turns +them back on. Filter on the current screen if you only meant to change the +toolbar. + +(MSLS also re-emits core's `widget_title` inside its widget. That one is the +ordinary widget convention rather than an MSLS extension point, and core +documents it.) + ## Content types ### msls_supported_post_types @@ -262,8 +374,8 @@ page via `add_settings_section()`. The callback receives the settings page slug as its only argument. **Always pass that argument through to `add_settings_section()` / `add_settings_field()` -instead of hard-coding a slug** — it is the only supported way to land on the -page MSLS actually renders: +instead of hard-coding a slug.** That is the only supported way to land on +the page MSLS renders: ```php add_action( @@ -281,8 +393,8 @@ specific settings section. The `{section}` suffix matches the section ID, for example `msls_admin_main_section`, `msls_admin_language_section`, `msls_admin_advanced_section`, or `msls_admin_rewrites_section`. Use it to add custom fields to one specific section without touching the others. Like -`msls_admin_register`, it hands you the page slug — as the first of its two -arguments, the second being the section ID. +`msls_admin_register`, it hands you the page slug as the first of its two +arguments; the second is the section ID. ### msls_admin_caps @@ -303,11 +415,11 @@ a PHP notice, so nothing lands in the error log. ### msls_reference_users -Filter on the array of reference users — keyed by user ID and valued by -nicename — used to populate the "reference user" dropdown. The array has -already been cut to `msls_max_reference_users_count` entries when it reaches -you. Use the hook to post-process the list, for example to relabel entries or -remove specific accounts. +Filter on the array of reference users (keyed by user ID, valued by nicename) +used to populate the "reference user" dropdown. The array has already been cut +to `msls_max_reference_users_count` entries when it reaches you. Use the hook +to post-process the list, for example to relabel entries or remove specific +accounts. ### msls_admin_validate @@ -408,7 +520,7 @@ or localize that heading. Filter on the admin URL produced for the small MSLS edit/new icons shown in list tables next to translatable rows. Override it to redirect the icon -target — for example to a custom translation workflow page instead of the +target, for example to a custom translation workflow page instead of the standard WordPress edit screen. ### msls_main_save @@ -426,6 +538,44 @@ elements (which default to the pattern `msls[]`). Override it when you need to integrate MSLS form fields into a different submit/parsing pipeline that expects another name shape. +## Admin – translation picker + +### msls_translation_picker_menu + +Filter on whether the "Add from Translation" entry is added to a post type's +submenu, receiving the decision (`true` by default) and the post type slug. +Return `false` to drop the entry; the picker page itself stays reachable, so +the "Add from Translation" button on the list screen keeps working. + +```php +add_filter( 'msls_translation_picker_menu', '__return_false' ); +``` + +Restricting it to pages, for example, means returning `false` for everything +else: + +```php +add_filter( + 'msls_translation_picker_menu', + function ( bool $show, string $post_type ): bool { + return 'page' === $post_type; + }, + 10, + 2 +); +``` + +### msls_translation_picker_button + +Filter on whether the "Add from Translation" button is injected next to "Add +New" on a post type's list screen, receiving the decision (`true` by default) +and the post type slug. Return `false` to suppress the button. Combine it with +`msls_translation_picker_menu` to remove both entry points at once. + +```php +add_filter( 'msls_translation_picker_button', '__return_false' ); +``` + ## REST API and Quick Create ### msls_quick_create_capability @@ -494,14 +644,14 @@ post a notice, or trigger follow-up jobs. Filter on the array of post fields that has been prepared for import, fired just after the "before import" action and before any importer runs. Use it -to rewrite fields globally — for example to localize URLs in `post_content` +to rewrite fields globally, for example to localize URLs in `post_content` or set a translation-specific `post_status`. ### msls_content_import_data_after_import Filter on the array of post fields after every importer has run. The hook also receives the `ImportLogger` and `Relations` objects, so it is the -right place to do a last pass over the merged result — for example to undo +right place to do a last pass over the merged result, for example to undo specific changes a particular importer made. ### msls_content_import_importers @@ -542,7 +692,7 @@ flavor alongside the built-ins. ### msls_content_import_{type}_selected Dynamic filter that picks which importer slug is "selected" for a given -factory — `{type}` is again one of the five factory types. The default is the +factory; `{type}` is again one of the five factory types. The default is the first entry of the factory's importers map. Use it to programmatically switch between competing importer implementations, for example based on the post type or destination blog. diff --git a/docs/snippets.md b/docs/snippets.md index b921d9987..abfe08d0d 100644 --- a/docs/snippets.md +++ b/docs/snippets.md @@ -15,9 +15,8 @@ locations, post types, and locales. In most cases you should use the WordPress function `get_locale()` when you need the language of the current blog. MSLS exposes a near-equivalent through -its blog collection — with the bonus of falling back to `en_US` when the -value is empty — and gives you the two-letter alpha-2 code as a separate -accessor. +its blog collection, which falls back to `en_US` when the value is empty and +gives you the two-letter alpha-2 code as a separate accessor. ```php $blog = msls_blog_collection()->get_current_blog(); @@ -32,7 +31,7 @@ the language switcher into a navigation menu. The example below renders the links as flag-only items and appends them to the primary menu only. It's safer to gate on the existence of `msls_output()` than to reference the -class directly — that way the theme degrades gracefully when MSLS is +class directly; that way the theme degrades gracefully when MSLS is deactivated. ```php @@ -67,8 +66,8 @@ $arr = $obj->get( 3 ); ## Iterate the blog collection -When you need to do something for every blog the plugin tracks — emit -alternate links, build a custom menu, populate a sitemap — iterate the +When you need to do something for every blog the plugin tracks (emit +alternate links, build a custom menu, populate a sitemap), iterate the collection directly: ```php @@ -97,8 +96,8 @@ if ( function_exists( 'msls_the_switcher' ) ) { } ``` -To capture the markup as a string — for example to embed it in another -component — use `msls_get_switcher()` and forward optional tag overrides: +To capture the markup as a string, for example to embed it in another +component, use `msls_get_switcher()` and forward optional tag overrides: ```php $markup = msls_get_switcher( @@ -138,7 +137,7 @@ add_filter( 'msls_head_hreflang', function ( $language ) { ## Restrict supported post types By default MSLS treats every public post type as translatable. The -`msls_supported_post_types` filter lets you opt specific post types out — for +`msls_supported_post_types` filter lets you opt specific post types out, for example a `feedback` CPT that you keep mono-lingual: ```php @@ -172,9 +171,9 @@ add_filter( 'msls_output_get', function ( string $url, $link, bool $is_current_b `LinkInterface` only declares `__toString()`; the values themselves live in the magic properties `Link` inherits from `Registry\GetSet`: `txt` is the blog description, `src` the flag-icon URL, and `alt` the locale. Casting the -object to a string renders the variant the user selected — which for three -of the four variants contains an `` element, so do not run the result -through `esc_html()`. +object to a string renders the variant the user selected. Three of the four +variants contain an `` element, so do not run the result through +`esc_html()`. ## Set the status of REST-created translations @@ -235,7 +234,7 @@ user of the blog. ## See also -- [Public API Functions](api.md) — the full reference for every `msls_*` +- [Public API Functions](api.md): the full reference for every `msls_*` helper. -- [Hooks Reference](hooks.md) — the full list of actions and filters the +- [Hooks Reference](hooks.md): the full list of actions and filters the plugin emits. diff --git a/includes/Admin/PostListActions.php b/includes/Admin/PostListActions.php index 8c1783bca..b437f53c9 100644 --- a/includes/Admin/PostListActions.php +++ b/includes/Admin/PostListActions.php @@ -46,9 +46,22 @@ public static function init(): void { return; } + if ( ! self::show_button( $post_type ) ) { + return; + } + add_action( 'admin_enqueue_scripts', array( self::class, 'inject_button' ) ); } + /** + * Whether the button is shown next to the post type's "Add New". + * + * @param string $post_type Post type of the list screen being rendered. + */ + public static function show_button( string $post_type ): bool { + return (bool) apply_filters( 'msls_translation_picker_button', true, $post_type ); + } + /** * Returns true when at least one other blog has MSLS active and could * therefore serve as a translation source. diff --git a/includes/Admin/TranslationPicker/Page.php b/includes/Admin/TranslationPicker/Page.php index 5c3344dfa..85e9e9cac 100644 --- a/includes/Admin/TranslationPicker/Page.php +++ b/includes/Admin/TranslationPicker/Page.php @@ -171,6 +171,11 @@ public static function reorder_submenu(): void { $parent = self::parent_slug( $post_type ); $slug = self::page_slug( $post_type ); + if ( ! self::show_menu( $post_type ) ) { + remove_submenu_page( $parent, $slug ); + continue; + } + if ( empty( $submenu[ $parent ] ) || ! is_array( $submenu[ $parent ] ) ) { continue; } @@ -219,6 +224,15 @@ public static function reorder_submenu(): void { } } + /** + * Whether the picker gets a menu entry below the post type. + * + * @param string $post_type Post type the entry would be registered for. + */ + public static function show_menu( string $post_type ): bool { + return (bool) apply_filters( 'msls_translation_picker_menu', true, $post_type ); + } + public static function parent_slug( string $post_type ): string { if ( '' === $post_type ) { return ''; diff --git a/includes/Admin/TranslationPicker/Table.php b/includes/Admin/TranslationPicker/Table.php index cda63706f..1935fc219 100644 --- a/includes/Admin/TranslationPicker/Table.php +++ b/includes/Admin/TranslationPicker/Table.php @@ -193,7 +193,7 @@ public function prepare_items(): void { $items = array(); $taxonomies = array_keys( $this->get_admin_column_taxonomies() ); - foreach ( $query->posts as $post ) { + foreach ( $query->posts ?? array() as $post ) { if ( ! $post instanceof \WP_Post ) { continue; } diff --git a/includes/Blog/Blog.php b/includes/Blog/Blog.php index 3ed39dce9..66e74b93a 100644 --- a/includes/Blog/Blog.php +++ b/includes/Blog/Blog.php @@ -3,6 +3,7 @@ namespace lloc\Msls\Blog; use lloc\Msls\Admin\Icon; +use lloc\Msls\Frontend\Pagination; use lloc\Msls\Options\OptionsInterface; /** @@ -123,7 +124,7 @@ public function get_alpha2() { */ public function get_url( $options ) { if ( msls_blog_collection()->get_current_blog_id() === $this->obj->userblog_id ) { - return $options->get_current_link(); + return Pagination::create()->get( $options->get_current_link(), $options, $this->get_language() ); } return $this->get_permalink( $options ); @@ -139,6 +140,7 @@ protected function get_permalink( OptionsInterface $options ) { $is_front_page = is_front_page(); $is_posts_page = ! $is_front_page && is_home(); + $pagination = Pagination::create(); switch_to_blog( $this->obj->userblog_id ); @@ -151,6 +153,10 @@ protected function get_permalink( OptionsInterface $options ) { } } + if ( is_string( $url ) ) { + $url = $pagination->get( $url, $options, $this->get_language() ); + } + restore_current_blog(); return $url; diff --git a/includes/Container.php b/includes/Container.php index 0dfda9e65..e41281d47 100644 --- a/includes/Container.php +++ b/includes/Container.php @@ -2,42 +2,41 @@ namespace lloc\Msls; -use DI\Container as DiContainer; -use DI\ContainerBuilder; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} /** - * Accessor for the PHP-DI container. - * - * The container is built on first use and kept for the rest of the request; requests that - * never ask for it never pay for it. + * Accessor for the service container, built on first use * * @package Msls */ final class Container { /** - * The container built by self::get(), kept for the rest of the request. + * The container built by self::get(), kept for the rest of the request * - * @var ?DiContainer + * @var ?ServiceContainer */ - private static ?DiContainer $container = null; + private static ?ServiceContainer $container = null; /** - * @throws \Exception If the container cannot be built. + * Gets the container of the current request + * + * @return ServiceContainer */ - public static function get(): DiContainer { + public static function get(): ServiceContainer { if ( null === self::$container ) { - $builder = new ContainerBuilder(); - $builder->addDefinitions( Plugin::plugin_dir_path( 'config.php' ) ); + $definitions = require Plugin::plugin_dir_path( 'config.php' ); - self::$container = $builder->build(); + self::$container = new ServiceContainer( is_array( $definitions ) ? $definitions : array() ); } return self::$container; } /** - * Drops the built container, so the next call to self::get() builds a new one. + * Drops the built container, so the next call to self::get() builds a new one */ public static function reset(): void { self::$container = null; diff --git a/includes/ContentImport/ImportLogger.php b/includes/ContentImport/ImportLogger.php index c510fab4b..a5619ec28 100644 --- a/includes/ContentImport/ImportLogger.php +++ b/includes/ContentImport/ImportLogger.php @@ -35,7 +35,7 @@ public function __construct( ImportCoordinates $import_coordinates ) { * * @param ImportLogger|null $logger */ - public function merge( ImportLogger $logger = null ): void { + public function merge( ?ImportLogger $logger = null ): void { if ( null === $logger ) { return; } diff --git a/includes/ContentImport/Importers/BaseImporter.php b/includes/ContentImport/Importers/BaseImporter.php index afacee614..78b670143 100644 --- a/includes/ContentImport/Importers/BaseImporter.php +++ b/includes/ContentImport/Importers/BaseImporter.php @@ -32,8 +32,8 @@ class BaseImporter implements Importer { */ public function __construct( ImportCoordinates $import_coordinates, - ImportLogger $logger = null, - Relations $relations = null + ?ImportLogger $logger = null, + ?Relations $relations = null ) { $this->import_coordinates = $import_coordinates; $this->logger = ! is_null( $logger ) ? $logger : new ImportLogger( $this->import_coordinates ); diff --git a/includes/ContentImport/Importers/PostMeta/Duplicating.php b/includes/ContentImport/Importers/PostMeta/Duplicating.php index 45dc1db64..28c30cbab 100644 --- a/includes/ContentImport/Importers/PostMeta/Duplicating.php +++ b/includes/ContentImport/Importers/PostMeta/Duplicating.php @@ -34,6 +34,7 @@ public function import( array $data ) { switch_to_blog( $source_blog_id ); $source_meta = get_post_custom( $source_post_id ); + $source_meta = is_array( $source_meta ) ? $source_meta : array(); switch_to_blog( $this->import_coordinates->dest_blog_id ); diff --git a/includes/ContentImport/Relations.php b/includes/ContentImport/Relations.php index e1544ff3b..161faad80 100644 --- a/includes/ContentImport/Relations.php +++ b/includes/ContentImport/Relations.php @@ -44,7 +44,7 @@ public function __construct( ImportCoordinates $import_coordinates ) { * * @param Relations|null $relations */ - public function merge( Relations $relations = null ): void { + public function merge( ?Relations $relations = null ): void { if ( null === $relations ) { return; } diff --git a/includes/Frontend/Output.php b/includes/Frontend/Output.php index 296c0e973..7d1359258 100644 --- a/includes/Frontend/Output.php +++ b/includes/Frontend/Output.php @@ -57,8 +57,9 @@ public function get( ?int $display, bool $filter = false, $exists = false ): arr $blogs = $this->collection->get_filtered( $filter ); if ( $blogs ) { - $mydata = Options::create(); - $link = Link::create( $display ); + $mydata = Options::create(); + $link = Link::create( $display ); + $pagination = Pagination::create(); foreach ( $blogs as $blog ) { $language = $blog->get_language(); @@ -68,7 +69,7 @@ public function get( ?int $display, bool $filter = false, $exists = false ): arr $is_current_blog = $this->collection->is_current_blog( $blog ); if ( $is_current_blog ) { - $url = $mydata->get_current_link(); + $url = $pagination->get( $mydata->get_current_link(), $mydata, $language ); $link->txt = $blog->get_description(); } else { switch_to_blog( $blog->userblog_id ); @@ -77,7 +78,7 @@ public function get( ?int $display, bool $filter = false, $exists = false ): arr restore_current_blog(); continue; } else { - $url = $mydata->get_permalink( $language ); + $url = $pagination->get( $mydata->get_permalink( $language ), $mydata, $language ); $link->txt = $blog->get_description(); } diff --git a/includes/Frontend/Pagination.php b/includes/Frontend/Pagination.php new file mode 100644 index 000000000..17acff105 --- /dev/null +++ b/includes/Frontend/Pagination.php @@ -0,0 +1,276 @@ + + * + * @var int + */ + protected int $page; + + /** + * Reads the pagination of the current request + */ + public static function create(): Pagination { + return new self( (int) get_query_var( 'paged' ), (int) get_query_var( 'page' ) ); + } + + /** + * @param int $paged + * @param int $page + */ + public function __construct( int $paged = 0, int $page = 0 ) { + $this->paged = max( 0, $paged ); + $this->page = max( 0, $page ); + } + + /** + * Checks if the request is paginated and the user did not opt out + */ + public function is_active(): bool { + if ( $this->paged < 2 && $this->page < 2 ) { + return false; + } + + /** + * Returns false to link to the first page as it was before 3.1 + * + * @param bool $preserve + * @param Pagination $pagination + * + * @since 3.1.0 + */ + return (bool) apply_filters( self::MSLS_PRESERVE_HOOK, true, $this ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- constant value is already prefixed with "msls_". + } + + /** + * Gets the kind of pagination of the current request + */ + public function get_context(): string { + return $this->paged > 1 ? Options::PAGINATION_ARCHIVE : Options::PAGINATION_SINGLE; + } + + /** + * Gets the page of the current request + */ + public function get_page(): int { + return $this->paged > 1 ? $this->paged : $this->page; + } + + /** + * Adds the page of the current request to a link of the switcher + * + * @param string $url + * @param OptionsInterface $options + * @param string $language + * + * @return string + */ + public function get( string $url, OptionsInterface $options, string $language ): string { + if ( '' === $url || ! $this->is_active() ) { + return $url; + } + + $page = $this->get_page(); + $context = $this->get_context(); + + $max = $options instanceof Options ? $options->get_max_pages( $language, $context ) : 0; + + /** + * Returns the number of pages the blog has for the current request + * + * @param int $max + * @param OptionsInterface $options + * @param string $language + * @param string $context + * + * @since 3.1.0 + */ + $max = (int) apply_filters( self::MSLS_MAX_PAGES_HOOK, $max, $options, $language, $context ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- constant value is already prefixed with "msls_". + + if ( $page <= $max ) { + $url = Options::PAGINATION_ARCHIVE === $context + ? $this->add_paged( $url, $page ) + : $this->add_page( $url, $page ); + } + + /** + * Returns the link of the switcher with the page of the current request + * + * @param string $url + * @param int $page + * @param OptionsInterface $options + * @param string $language + * + * @since 3.1.0 + */ + return (string) apply_filters( self::MSLS_GET_HOOK, $url, $page, $options, $language ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- constant value is already prefixed with "msls_". + } + + /** + * Adds the page of an archive + * + * @param string $url + * @param int $page + * + * @return string + */ + protected function add_paged( string $url, int $page ): string { + if ( ! $this->using_permalinks( $url ) ) { + return (string) add_query_arg( 'paged', $page, $url ); + } + + return $this->add_path( $url, $this->get_pagination_base() . '/' . $page ); + } + + /** + * Adds the page of a post which is split by + * + * @param string $url + * @param int $page + * + * @return string + */ + protected function add_page( string $url, int $page ): string { + if ( ! $this->using_permalinks( $url ) ) { + return (string) add_query_arg( 'page', $page, $url ); + } + + // A static front page is the one exception where WordPress uses the pagination base here, see _wp_link_page(). + $path = untrailingslashit( $url ) === untrailingslashit( home_url( '/' ) ) + ? $this->get_pagination_base() . '/' . $page + : (string) $page; + + return $this->add_path( $url, $path ); + } + + /** + * Appends a path to the link and keeps a query string in place + * + * @param string $url + * @param string $path + * + * @return string + */ + protected function add_path( string $url, string $path ): string { + $query = ''; + $pos = strpos( $url, '?' ); + + if ( false !== $pos ) { + $query = substr( $url, $pos ); + $url = substr( $url, 0, $pos ); + } + + $url = trailingslashit( $url ) . $path; + + return ( $this->using_trailing_slashes() ? trailingslashit( $url ) : untrailingslashit( $url ) ) . $query; + } + + /** + * Checks if the blog the link belongs to ends its permalinks with a slash + */ + protected function using_trailing_slashes(): bool { + $structure = (string) get_option( 'permalink_structure', '' ); + + return '' !== $structure && str_ends_with( $structure, '/' ); + } + + /** + * Checks if the page belongs into the path of the link + * + * @param string $url + * + * @return bool + */ + protected function using_permalinks( string $url ): bool { + if ( '' === (string) get_option( 'permalink_structure', '' ) ) { + return false; + } + + return ! self::has_query_route( $url ); + } + + /** + * Checks if the query string of the link routes the request + * + * @param string $url + * + * @return bool + */ + protected static function has_query_route( string $url ): bool { + $pos = strpos( $url, '?' ); + + if ( false === $pos ) { + return false; + } + + parse_str( substr( $url, $pos + 1 ), $args ); + + return array() !== array_intersect( self::QUERY_ROUTE_VARS, array_keys( $args ) ); + } + + /** + * Gets the rewrite base of a paginated archive + */ + protected function get_pagination_base(): string { + global $wp_rewrite; + + return isset( $wp_rewrite->pagination_base ) ? (string) $wp_rewrite->pagination_base : 'page'; + } +} diff --git a/includes/Options/Options.php b/includes/Options/Options.php index 9c2699afe..ff7f93a0e 100644 --- a/includes/Options/Options.php +++ b/includes/Options/Options.php @@ -44,6 +44,9 @@ class Options extends GetSet implements OptionsInterface { public const PREFIX = 'msls'; public const SEPARATOR = ''; + public const PAGINATION_ARCHIVE = 'paged'; + public const PAGINATION_SINGLE = 'single_paged'; + /** * The name of the option in the database * @@ -256,6 +259,119 @@ public function get_postlink( $language ) { return ''; } + /** + * Gets the number of pages this blog has for the current request + * + * @param string $language + * @param string $context + * + * @return int + */ + public function get_max_pages( string $language, string $context = self::PAGINATION_ARCHIVE ): int { + if ( self::PAGINATION_SINGLE === $context ) { + return is_front_page() ? self::count_content_pages( (int) get_option( 'page_on_front' ) ) : 0; + } + + if ( is_home() ) { + return self::posts_to_pages( self::count_published( 'post' ) ); + } + + return is_search() ? self::posts_to_pages( self::count_search_posts() ) : 0; + } + + /** + * Gets the number of published posts of a post type in the current blog + * + * @param string $post_type + * + * @return int + */ + protected static function count_published( string $post_type ): int { + $counts = wp_count_posts( $post_type ); + + return (int) ( $counts->publish ?? 0 ); + } + + /** + * Gets the number of published posts the search of the current request finds in the current blog + * + * @return int + */ + protected static function count_search_posts(): int { + $search = (string) get_search_query( false ); + + return '' === $search ? 0 : self::count_query_posts( array( 's' => $search ) ); + } + + /** + * Gets the number of published posts a query finds in the current blog + * + * @param array $args + * + * @return int + */ + protected static function count_query_posts( array $args ): int { + $query = new \WP_Query( + array_merge( + array( + 'post_status' => 'publish', + 'fields' => 'ids', + 'posts_per_page' => 1, + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ), + $args + ) + ); + + return (int) $query->found_posts; + } + + /** + * Gets the number of pages a post is split into by in the current blog + * + * @param int $post_id + * + * @return int + */ + protected static function count_content_pages( int $post_id ): int { + if ( 0 === $post_id ) { + return 0; + } + + $post = get_post( $post_id ); + if ( is_null( $post ) ) { + return 0; + } + + $content = str_replace( + array( "\n\n", "\n", "\n" ), + '', + (string) $post->post_content + ); + + $count = substr_count( $content, '' ); + if ( 0 === $count ) { + return 1; + } + + return str_starts_with( $content, '' ) ? $count : $count + 1; + } + + /** + * Gets the number of pages a list of posts is split into in the current blog + * + * @param int $count + * + * @return int + */ + protected static function posts_to_pages( int $count ): int { + $per_page = (int) get_option( 'posts_per_page', 10 ); + + return $per_page > 0 ? (int) ceil( $count / $per_page ) : 0; + } + /** * Get the queried taxonomy * diff --git a/includes/Options/Post/Post.php b/includes/Options/Post/Post.php index 67b392a5e..4c3c2a490 100644 --- a/includes/Options/Post/Post.php +++ b/includes/Options/Post/Post.php @@ -55,4 +55,35 @@ public function get_postlink( $language ) { public function get_current_link(): string { return (string) get_permalink( $this->get_arg( 0, 0 ) ); } + + /** + * Gets the number of pages this blog has for the current request + * + * @param string $language + * @param string $context + * + * @return int + */ + public function get_max_pages( string $language, string $context = self::PAGINATION_ARCHIVE ): int { + if ( self::PAGINATION_ARCHIVE === $context ) { + return is_home() ? self::posts_to_pages( self::count_published( 'post' ) ) : 0; + } + + return self::count_content_pages( $this->get_post_id( $language ) ); + } + + /** + * Gets the post this options object builds a link for in the current blog + * + * @param string $language + * + * @return int + */ + protected function get_post_id( string $language ): int { + if ( $this->has_value( $language ) ) { + return (int) $this->__get( $language ); + } + + return ms_is_switched() ? 0 : $this->get_arg( 0, 0 ); + } } diff --git a/includes/Options/Query/PostType.php b/includes/Options/Query/PostType.php index 324cafa00..e09ab83d0 100644 --- a/includes/Options/Query/PostType.php +++ b/includes/Options/Query/PostType.php @@ -53,4 +53,20 @@ public function has_value( string $language ): bool { public function get_current_link(): string { return (string) get_post_type_archive_link( $this->post_type ); } + + /** + * Gets the number of pages this blog has for the current request + * + * @param string $language + * @param string $context + * + * @return int + */ + public function get_max_pages( string $language, string $context = self::PAGINATION_ARCHIVE ): int { + if ( self::PAGINATION_ARCHIVE !== $context || ! $this->has_value( $language ) ) { + return 0; + } + + return self::posts_to_pages( self::count_published( $this->post_type ) ); + } } diff --git a/includes/Options/Query/Query.php b/includes/Options/Query/Query.php index d89889597..500819fd1 100644 --- a/includes/Options/Query/Query.php +++ b/includes/Options/Query/Query.php @@ -93,4 +93,20 @@ public function get_postlink( $language ) { return ''; } + + /** + * Gets the number of pages this blog has for the current request + * + * @param string $language + * @param string $context + * + * @return int + */ + public function get_max_pages( string $language, string $context = self::PAGINATION_ARCHIVE ): int { + if ( self::PAGINATION_ARCHIVE !== $context || ! $this->has_value( $language ) ) { + return 0; + } + + return self::posts_to_pages( (int) $this->__get( $language ) ); + } } diff --git a/includes/Options/Tax/Tax.php b/includes/Options/Tax/Tax.php index e255e6de6..dc1d695d1 100644 --- a/includes/Options/Tax/Tax.php +++ b/includes/Options/Tax/Tax.php @@ -115,6 +115,77 @@ public function get_current_link(): string { return $this->get_term_link( $this->get_arg( 0, 0 ) ); } + /** + * Gets the number of pages this blog has for the current request + * + * @param string $language + * @param string $context + * + * @return int + */ + public function get_max_pages( string $language, string $context = self::PAGINATION_ARCHIVE ): int { + if ( self::PAGINATION_ARCHIVE !== $context ) { + return 0; + } + + $taxonomy = $this->get_tax_query(); + $term_id = $this->get_term_id( $language ); + + if ( empty( $taxonomy ) || empty( $term_id ) ) { + return 0; + } + + $term = get_term( $term_id, $taxonomy ); + + return $term instanceof \WP_Term ? self::posts_to_pages( self::count_term_posts( $term ) ) : 0; + } + + /** + * Gets the term this options object builds a link for in the current blog + * + * @param string $language + * + * @return int + */ + protected function get_term_id( string $language ): int { + if ( $this->has_value( $language ) ) { + return (int) $this->__get( $language ); + } + + return ms_is_switched() ? 0 : $this->get_arg( 0, 0 ); + } + + /** + * Gets the number of published posts an archive of the term holds in the current blog + * + * @param \WP_Term $term + * + * @return int + */ + protected static function count_term_posts( \WP_Term $term ): int { + $children = get_term_children( $term->term_id, $term->taxonomy ); + + if ( is_wp_error( $children ) || empty( $children ) ) { + return (int) $term->count; + } + + $taxonomy = get_taxonomy( $term->taxonomy ); + + return self::count_query_posts( + array( + 'post_type' => $taxonomy instanceof \WP_Taxonomy ? $taxonomy->object_type : 'post', + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the count of a hierarchical term is not available anywhere else. + array( + 'taxonomy' => $term->taxonomy, + 'field' => 'term_id', + 'terms' => $term->term_id, + 'include_children' => true, + ), + ), + ) + ); + } + /** * Wraps the call to get_term_link * diff --git a/includes/ServiceContainer.php b/includes/ServiceContainer.php new file mode 100644 index 000000000..3ea8b0d32 --- /dev/null +++ b/includes/ServiceContainer.php @@ -0,0 +1,89 @@ + + */ + private array $definitions; + + /** + * Entries the container has resolved, keyed by id + * + * @var array + */ + private array $resolved = array(); + + /** + * @param array $definitions + */ + public function __construct( array $definitions = array() ) { + $this->definitions = $definitions; + } + + /** + * Checks if the container can resolve an id + * + * @param string $id + * + * @return bool + */ + public function has( string $id ): bool { + return array_key_exists( $id, $this->resolved ) + || array_key_exists( $id, $this->definitions ) + || class_exists( $id ); + } + + /** + * Resolves an id and keeps the entry for the rest of the request + * + * @param string $id + * + * @return mixed + * + * @throws \RuntimeException If the id is neither defined nor an existing class. + */ + public function get( string $id ) { + if ( ! array_key_exists( $id, $this->resolved ) ) { + $this->resolved[ $id ] = $this->resolve( $id ); + } + + return $this->resolved[ $id ]; + } + + /** + * Builds the entry of an id + * + * @param string $id + * + * @return mixed + * + * @throws \RuntimeException If the id is neither defined nor an existing class. + */ + private function resolve( string $id ) { + if ( array_key_exists( $id, $this->definitions ) ) { + $definition = $this->definitions[ $id ]; + + return $definition instanceof \Closure ? $definition( $this ) : $definition; + } + + if ( ! class_exists( $id ) ) { + throw new \RuntimeException( esc_html( sprintf( 'The container has no entry for "%s".', $id ) ) ); + } + + return new $id(); + } +} diff --git a/languages/default.pot b/languages/default.pot index 955eb438b..f492fc454 100644 --- a/languages/default.pot +++ b/languages/default.pot @@ -2,7 +2,7 @@ # This file is distributed under the GPLv2 or later. msgid "" msgstr "" -"Project-Id-Version: Multisite Language Switcher 3.0.0\n" +"Project-Id-Version: Multisite Language Switcher 3.1.0\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/multisite-language-switcher\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/readme.txt b/readme.txt index ab82daf41..d7fdff8e3 100644 --- a/readme.txt +++ b/readme.txt @@ -6,7 +6,7 @@ Tags: multilingual, multisite, language, switcher, localization Requires at least: 6.1 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 3.0.3 +Stable tag: 3.1.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -93,6 +93,15 @@ Please visit the [MSLS website](https://msls.co/) or use the [WordPress support == Changelog == += 3.1.0 = + +* New: the switcher keeps the page of a paginated request. A visitor on page 3 of an archive is sent to page 3 of the translated archive instead of the first page, for the pages of an archive as well as for a post which is split by ``. +* New: a page is only added when the other blog has it. The new `Options::get_max_pages()` decides that, and a link which would be out of range still points to the first page. +* New: `msls_preserve_pagination` switches the feature off, `msls_pagination_max_pages` corrects the number of pages MSLS calculates, and `msls_pagination_get` adjusts the finished link. +* New: `msls_translation_picker_menu` hides the "Add from Translation" entry in the menu of a post type, `msls_translation_picker_button` hides the button next to "Add New". Both get the post type, so a network which only translates pages can keep the entry points there and drop them everywhere else. +* Fix: PHP 8.4 raised deprecation notices on every page load. They came from the bundled PHP-DI library, which the plugin no longer ships. +* Fix: three parameters of the content import were declared in a way PHP 8.4 deprecates. + = 3.0.3 = * Fixed: on PHP 7.4 the plugin's dependencies could not be installed, because one of them required PHP 8. MSLS supports PHP 7.4 again, as documented. @@ -138,6 +147,11 @@ The full history is kept in the separate [Changelog](https://github.com/lloc/Mul == Upgrade Notice == += 3.1.0 = + +The switcher now keeps the page of paginated archives instead of linking to page one. +Restore the old behaviour with `add_filter( 'msls_preserve_pagination', '__return_false' );`. + = 3.0.3 = Maintenance release for PHP 7.4 installations. One of the plugin's dependencies required diff --git a/src/msls-widget-block/block.json b/src/msls-widget-block/block.json index bbdfe9ef4..072c2a693 100644 --- a/src/msls-widget-block/block.json +++ b/src/msls-widget-block/block.json @@ -5,7 +5,7 @@ "icon": "translation", "category": "widgets", "name": "lloc/msls-widget-block", - "version": "3.0.3", + "version": "3.1.0", "description": "Review the settings for the Multisite Language Switcher plugin, as the block utilizes the API function `msls_the_switcher()` for its output.", "example": {}, "supports": { diff --git a/tests/phpunit/Admin/TestPostListActions.php b/tests/phpunit/Admin/TestPostListActions.php new file mode 100644 index 000000000..ff1c7b1c0 --- /dev/null +++ b/tests/phpunit/Admin/TestPostListActions.php @@ -0,0 +1,22 @@ +once()->with( true, 'post' )->andReturnFirstArg(); + + $this->assertTrue( PostListActions::show_button( 'post' ) ); + } + + public function test_show_button_can_be_filtered_off(): void { + Filters\expectApplied( 'msls_translation_picker_button' )->once()->with( true, 'page' )->andReturn( false ); + + $this->assertFalse( PostListActions::show_button( 'page' ) ); + } +} diff --git a/tests/phpunit/Admin/TranslationPicker/TestPage.php b/tests/phpunit/Admin/TranslationPicker/TestPage.php index af85aaa01..c6cd18888 100644 --- a/tests/phpunit/Admin/TranslationPicker/TestPage.php +++ b/tests/phpunit/Admin/TranslationPicker/TestPage.php @@ -2,6 +2,7 @@ namespace lloc\MslsTests\Admin\TranslationPicker; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; use lloc\Msls\Admin\TranslationPicker\Page; use lloc\MslsTests\MslsUnitTestCase; @@ -115,4 +116,16 @@ public static function save_per_page_option_provider(): array { public function test_save_per_page_option( string $option, string $value, $expected ): void { $this->assertSame( $expected, Page::save_per_page_option( false, $option, $value ) ); } + + public function test_show_menu_defaults_to_true(): void { + Filters\expectApplied( 'msls_translation_picker_menu' )->once()->with( true, 'post' )->andReturnFirstArg(); + + $this->assertTrue( Page::show_menu( 'post' ) ); + } + + public function test_show_menu_can_be_filtered_off(): void { + Filters\expectApplied( 'msls_translation_picker_menu' )->once()->with( true, 'page' )->andReturn( false ); + + $this->assertFalse( Page::show_menu( 'page' ) ); + } } diff --git a/tests/phpunit/Blog/TestBlog.php b/tests/phpunit/Blog/TestBlog.php index 8ee9dee6d..de3f1ac72 100644 --- a/tests/phpunit/Blog/TestBlog.php +++ b/tests/phpunit/Blog/TestBlog.php @@ -11,6 +11,12 @@ final class TestBlog extends MslsUnitTestCase { + protected function setUp(): void { + parent::setUp(); + + Functions\when( 'get_query_var' )->justReturn( 0 ); + } + private function BlogFactory(): Blog { Functions\expect( 'get_blog_option' )->once()->andReturn( 'it_IT' ); Functions\expect( 'plugin_dir_path' )->atLeast( 1 )->andReturn( dirname( __DIR__, 3 ) . '/' ); diff --git a/tests/phpunit/Frontend/TestContentFilter.php b/tests/phpunit/Frontend/TestContentFilter.php index 455563bb4..9a8299d56 100644 --- a/tests/phpunit/Frontend/TestContentFilter.php +++ b/tests/phpunit/Frontend/TestContentFilter.php @@ -13,6 +13,12 @@ final class TestContentFilter extends MslsUnitTestCase { + protected function setUp(): void { + parent::setUp(); + + Functions\when( 'get_query_var' )->justReturn( 0 ); + } + public function test_init(): void { $options = \Mockery::mock( Options::class ); diff --git a/tests/phpunit/Frontend/TestOutput.php b/tests/phpunit/Frontend/TestOutput.php index 5cacf7be5..f38cfe235 100644 --- a/tests/phpunit/Frontend/TestOutput.php +++ b/tests/phpunit/Frontend/TestOutput.php @@ -14,6 +14,12 @@ final class TestOutput extends MslsUnitTestCase { + protected function setUp(): void { + parent::setUp(); + + Functions\when( 'get_query_var' )->justReturn( 0 ); + } + private function OutputFactory(): Output { $options = \Mockery::mock( Options::class ); diff --git a/tests/phpunit/Frontend/TestPagination.php b/tests/phpunit/Frontend/TestPagination.php new file mode 100644 index 000000000..45334cbd2 --- /dev/null +++ b/tests/phpunit/Frontend/TestPagination.php @@ -0,0 +1,205 @@ +pagination_base = 'page'; + + Functions\when( 'trailingslashit' )->alias( + function ( string $string ): string { + return rtrim( $string, '/\\' ) . '/'; + } + ); + Functions\when( 'untrailingslashit' )->alias( + function ( string $string ): string { + return rtrim( $string, '/\\' ); + } + ); + Functions\when( 'add_query_arg' )->alias( + function ( string $key, $value, string $url ): string { + return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . $key . '=' . $value; + } + ); + Functions\when( 'home_url' )->justReturn( 'https://example.com/' ); + } + + protected function tearDown(): void { + unset( $GLOBALS['wp_rewrite'] ); + + parent::tearDown(); + } + + private function optionsWithPages( int $max_pages ): OptionsInterface { + $options = \Mockery::mock( Options::class ); + $options->shouldReceive( 'get_max_pages' )->andReturn( $max_pages ); + + return $options; + } + + public function test_create_reads_the_query_vars(): void { + Functions\expect( 'get_query_var' )->once()->with( 'paged' )->andReturn( '3' ); + Functions\expect( 'get_query_var' )->once()->with( 'page' )->andReturn( '0' ); + + $test = Pagination::create(); + + $this->assertTrue( $test->is_active() ); + $this->assertEquals( 3, $test->get_page() ); + $this->assertEquals( Options::PAGINATION_ARCHIVE, $test->get_context() ); + } + + /** + * @return array + */ + public static function is_active_provider(): array { + return array( + 'not paginated at all' => array( 0, 0, false ), + 'first page of a list' => array( 1, 0, false ), + 'first page of a post' => array( 0, 1, false ), + 'third page of a list' => array( 3, 0, true ), + 'second page of a post' => array( 0, 2, true ), + 'negative query var' => array( -3, 0, false ), + ); + } + + #[DataProvider( 'is_active_provider' )] + public function test_is_active( int $paged, int $page, bool $expected ): void { + $this->assertEquals( $expected, ( new Pagination( $paged, $page ) )->is_active() ); + } + + public function test_is_active_can_be_switched_off(): void { + Filters\expectApplied( Pagination::MSLS_PRESERVE_HOOK )->once()->andReturn( false ); + + $this->assertFalse( ( new Pagination( 3 ) )->is_active() ); + } + + public function test_get_returns_an_empty_url_untouched(): void { + $this->assertEquals( '', ( new Pagination( 3 ) )->get( '', $this->optionsWithPages( 5 ), 'it_IT' ) ); + } + + public function test_get_leaves_an_unpaginated_request_alone(): void { + $url = 'https://example.com/main-dishes/'; + + $this->assertEquals( $url, ( new Pagination( 0 ) )->get( $url, $this->optionsWithPages( 5 ), 'it_IT' ) ); + } + + public function test_get_adds_the_page_of_an_archive(): void { + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); + + $test = new Pagination( 3 ); + + $this->assertEquals( + 'https://example.com/main-dishes/page/3/', + $test->get( 'https://example.com/main-dishes/', $this->optionsWithPages( 5 ), 'it_IT' ) + ); + } + + public function test_get_falls_back_to_the_first_page_when_the_blog_is_shorter(): void { + $url = 'https://example.com/main-dishes/'; + + $this->assertEquals( $url, ( new Pagination( 3 ) )->get( $url, $this->optionsWithPages( 2 ), 'it_IT' ) ); + } + + public function test_get_uses_the_query_string_without_pretty_permalinks(): void { + Functions\expect( 'get_option' )->once()->andReturn( '' ); + + $this->assertEquals( + 'https://example.com/?cat=7&paged=3', + ( new Pagination( 3 ) )->get( 'https://example.com/?cat=7', $this->optionsWithPages( 5 ), 'it_IT' ) + ); + } + + public function test_get_keeps_a_query_string_behind_the_page(): void { + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); + + $this->assertEquals( + 'https://example.com/main-dishes/page/3/?orderby=title', + ( new Pagination( 3 ) )->get( 'https://example.com/main-dishes/?orderby=title', $this->optionsWithPages( 5 ), 'it_IT' ) + ); + } + + public function test_get_uses_the_query_string_for_an_unrewritten_post_type(): void { + Functions\expect( 'get_option' )->once()->andReturn( '/%postname%/' ); + + $this->assertEquals( + 'https://example.com/?post_type=book&paged=3', + ( new Pagination( 3 ) )->get( 'https://example.com/?post_type=book', $this->optionsWithPages( 5 ), 'it_IT' ) + ); + } + + public function test_get_uses_the_query_string_for_a_post_without_a_pretty_link(): void { + Functions\expect( 'get_option' )->once()->andReturn( '/%postname%/' ); + + $this->assertEquals( + 'https://example.com/?p=123&page=2', + ( new Pagination( 0, 2 ) )->get( 'https://example.com/?p=123', $this->optionsWithPages( 4 ), 'it_IT' ) + ); + } + + public function test_get_drops_the_trailing_slash_of_the_target_blog(): void { + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%' ); + + $this->assertEquals( + 'https://example.com/main-dishes/page/3', + ( new Pagination( 3 ) )->get( 'https://example.com/main-dishes/', $this->optionsWithPages( 5 ), 'it_IT' ) + ); + } + + public function test_get_adds_the_page_of_a_post(): void { + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); + + $this->assertEquals( + 'https://example.com/my-post/2/', + ( new Pagination( 0, 2 ) )->get( 'https://example.com/my-post/', $this->optionsWithPages( 4 ), 'it_IT' ) + ); + } + + public function test_get_uses_the_pagination_base_for_a_static_front_page(): void { + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); + + $this->assertEquals( + 'https://example.com/page/2/', + ( new Pagination( 0, 2 ) )->get( 'https://example.com/', $this->optionsWithPages( 4 ), 'it_IT' ) + ); + } + + public function test_get_respects_a_corrected_number_of_pages(): void { + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); + Filters\expectApplied( Pagination::MSLS_MAX_PAGES_HOOK )->once()->andReturn( 9 ); + + $this->assertEquals( + 'https://example.com/main-dishes/page/3/', + ( new Pagination( 3 ) )->get( 'https://example.com/main-dishes/', $this->optionsWithPages( 0 ), 'it_IT' ) + ); + } + + public function test_get_can_be_filtered(): void { + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); + Filters\expectApplied( Pagination::MSLS_GET_HOOK )->once()->andReturn( 'https://example.com/filtered/' ); + + $this->assertEquals( + 'https://example.com/filtered/', + ( new Pagination( 3 ) )->get( 'https://example.com/main-dishes/', $this->optionsWithPages( 5 ), 'it_IT' ) + ); + } + + public function test_get_ignores_an_options_object_it_cannot_ask(): void { + $options = \Mockery::mock( OptionsInterface::class ); + + $url = 'https://example.com/main-dishes/'; + + $this->assertEquals( $url, ( new Pagination( 3 ) )->get( $url, $options, 'it_IT' ) ); + } +} diff --git a/tests/phpunit/Options/Post/TestPost.php b/tests/phpunit/Options/Post/TestPost.php index ea4550074..9a286e4fb 100644 --- a/tests/phpunit/Options/Post/TestPost.php +++ b/tests/phpunit/Options/Post/TestPost.php @@ -18,6 +18,87 @@ private function OptionsPostFactory(): Post { return new Post( 42 ); } + public function test_get_max_pages_counts_the_nextpage_quicktags(): void { + $post = \Mockery::mock( '\WP_Post' ); + $post->post_content = 'OneTwoThree'; + + Functions\expect( 'get_post' )->once()->with( 42 )->andReturn( $post ); + + $test = $this->OptionsPostFactory(); + + $this->assertEquals( 3, $test->get_max_pages( 'de_DE', Post::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_falls_back_to_the_queried_post(): void { + $post = \Mockery::mock( '\WP_Post' ); + $post->post_content = 'Not split at all'; + + Functions\expect( 'ms_is_switched' )->once()->andReturnFalse(); + Functions\expect( 'get_post' )->once()->with( 42 )->andReturn( $post ); + + $test = $this->OptionsPostFactory(); + + $this->assertEquals( 1, $test->get_max_pages( 'es_ES', Post::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_without_a_translation_in_a_switched_blog(): void { + Functions\expect( 'ms_is_switched' )->once()->andReturnTrue(); + Functions\expect( 'get_post' )->never(); + + $test = $this->OptionsPostFactory(); + + $this->assertEquals( 0, $test->get_max_pages( 'es_ES', Post::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_ignores_a_leading_nextpage(): void { + $post = \Mockery::mock( '\WP_Post' ); + $post->post_content = 'OneTwo'; + + Functions\expect( 'get_post' )->once()->with( 42 )->andReturn( $post ); + + $test = $this->OptionsPostFactory(); + + $this->assertEquals( 2, $test->get_max_pages( 'de_DE', Post::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_ignores_a_leading_nextpage_on_its_own_line(): void { + $post = \Mockery::mock( '\WP_Post' ); + $post->post_content = "\n\nOneTwo"; + + Functions\expect( 'get_post' )->once()->with( 42 )->andReturn( $post ); + + $test = $this->OptionsPostFactory(); + + $this->assertEquals( 2, $test->get_max_pages( 'de_DE', Post::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_without_a_post(): void { + Functions\expect( 'get_post' )->once()->andReturnNull(); + + $test = $this->OptionsPostFactory(); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', Post::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_of_the_posts_page(): void { + Functions\when( 'is_home' )->justReturn( true ); + Functions\when( 'wp_count_posts' )->justReturn( (object) array( 'publish' => 25 ) ); + + $test = $this->OptionsPostFactory(); + + Functions\expect( 'get_option' )->once()->with( 'posts_per_page', 10 )->andReturn( 10 ); + + $this->assertEquals( 3, $test->get_max_pages( 'de_DE', Post::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_of_a_single_post_request(): void { + Functions\when( 'is_home' )->justReturn( false ); + + $test = $this->OptionsPostFactory(); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', Post::PAGINATION_ARCHIVE ) ); + } + public function test_get_postlink_not_has_value(): void { $test = $this->OptionsPostFactory(); diff --git a/tests/phpunit/Options/Query/TestAuthor.php b/tests/phpunit/Options/Query/TestAuthor.php index 98672d2bb..0e2e155c1 100644 --- a/tests/phpunit/Options/Query/TestAuthor.php +++ b/tests/phpunit/Options/Query/TestAuthor.php @@ -30,6 +30,29 @@ public function test_has_value_false(): void { $this->assertFalse( $this->OptionsQueryAuthorFactory( 0 )->has_value( 'de_DE' ) ); } + public function test_get_max_pages_from_the_counted_posts(): void { + Functions\expect( 'get_option' )->once()->andReturn( array() ); + Functions\expect( 'get_queried_object_id' )->once()->andReturn( 17 ); + + $sql_cacher = \Mockery::mock( SqlCacher::class ); + $sql_cacher->shouldReceive( 'prepare' )->andReturn( 'SQL Query String' ); + $sql_cacher->shouldReceive( 'get_var' )->andReturn( 25 ); + + $test = new Author( $sql_cacher ); + + Functions\expect( 'get_option' )->once()->with( 'posts_per_page', 10 )->andReturn( 10 ); + + $this->assertEquals( 3, $test->get_max_pages( 'de_DE', Author::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_without_posts(): void { + $this->assertEquals( 0, $this->OptionsQueryAuthorFactory( 0 )->get_max_pages( 'de_DE', Author::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_of_a_post_split_by_nextpage(): void { + $this->assertEquals( 0, $this->OptionsQueryAuthorFactory( 17 )->get_max_pages( 'de_DE', Author::PAGINATION_SINGLE ) ); + } + public function test_get_current_link_method(): void { Functions\expect( 'get_author_posts_url' )->once()->andReturn( 'https://msls.co/queried-author' ); diff --git a/tests/phpunit/Options/Query/TestPostType.php b/tests/phpunit/Options/Query/TestPostType.php index 25a99e074..471b73ef8 100644 --- a/tests/phpunit/Options/Query/TestPostType.php +++ b/tests/phpunit/Options/Query/TestPostType.php @@ -53,4 +53,30 @@ public function test_get_current_link(): void { $this->assertEquals( 'https://msls.co/queried-posttype', $test->get_current_link() ); } + + public function test_get_max_pages(): void { + $post_type = \Mockery::mock( '\WP_Post_Type' ); + Functions\expect( 'get_post_type_object' )->once()->andReturn( $post_type ); + + $test = $this->OptionsQueryPostTypeFactory(); + + Functions\expect( 'wp_count_posts' )->once()->with( 'queried-posttype' )->andReturn( (object) array( 'publish' => 25 ) ); + Functions\expect( 'get_option' )->once()->with( 'posts_per_page', 10 )->andReturn( 10 ); + + $this->assertEquals( 3, $test->get_max_pages( 'de_DE', PostType::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_without_the_post_type(): void { + Functions\expect( 'get_post_type_object' )->once()->andReturnNull(); + + $test = $this->OptionsQueryPostTypeFactory(); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', PostType::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_of_a_post_split_by_nextpage(): void { + $test = $this->OptionsQueryPostTypeFactory(); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', PostType::PAGINATION_SINGLE ) ); + } } diff --git a/tests/phpunit/Options/Tax/TestTax.php b/tests/phpunit/Options/Tax/TestTax.php index 4774fb60c..0ccf6dfa5 100644 --- a/tests/phpunit/Options/Tax/TestTax.php +++ b/tests/phpunit/Options/Tax/TestTax.php @@ -7,6 +7,7 @@ use lloc\Msls\Options\Tax\Tax; use lloc\Msls\Options\Tax\Term; use lloc\MslsTests\MslsUnitTestCase; +use lloc\MslsTests\WP_Query; use function Brain\Monkey\Functions; @@ -37,6 +38,115 @@ public function test_create_post_tag(): void { $this->assertInstanceOf( Term::class, Tax::create() ); } + private function TaxWithTranslatedTermFactory(): Tax { + Functions\expect( 'get_option' )->once()->with( 'msls_term_42' )->andReturn( array( 'de_DE' => 7 ) ); + + return new Tax( 42 ); + } + + private function expect_tax_query( string $taxonomy ): void { + Functions\expect( 'is_woocommerce' )->once()->andReturn( false ); + + $GLOBALS['wp_query'] = new \stdClass(); + $GLOBALS['wp_query']->tax_query = new \stdClass(); + $GLOBALS['wp_query']->tax_query->queries = array( array( 'taxonomy' => $taxonomy ) ); + } + + private function TermFactory( int $term_id, int $count ): \WP_Term { + $term = \Mockery::mock( '\WP_Term' ); + $term->term_id = $term_id; + $term->taxonomy = 'category'; + $term->count = $count; + + return $term; + } + + public function test_get_max_pages_from_the_term_count(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + $this->expect_tax_query( 'category' ); + + $term = $this->TermFactory( 7, 25 ); + + Functions\expect( 'get_term' )->once()->with( 7, 'category' )->andReturn( $term ); + Functions\expect( 'get_term_children' )->once()->with( 7, 'category' )->andReturn( array() ); + Functions\expect( 'get_option' )->once()->with( 'posts_per_page', 10 )->andReturn( 10 ); + + $this->assertEquals( 3, $test->get_max_pages( 'de_DE', Tax::PAGINATION_ARCHIVE ) ); + + unset( $GLOBALS['wp_query'] ); + } + + public function test_get_max_pages_counts_the_children_of_a_term(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + $this->expect_tax_query( 'category' ); + + $term = $this->TermFactory( 7, 0 ); + + $taxonomy = \Mockery::mock( '\WP_Taxonomy' ); + $taxonomy->object_type = array( 'post' ); + + Functions\expect( 'get_term' )->once()->with( 7, 'category' )->andReturn( $term ); + Functions\expect( 'get_term_children' )->once()->with( 7, 'category' )->andReturn( array( 8, 9 ) ); + Functions\expect( 'is_wp_error' )->once()->andReturnFalse(); + Functions\expect( 'get_taxonomy' )->once()->with( 'category' )->andReturn( $taxonomy ); + Functions\expect( 'get_option' )->once()->with( 'posts_per_page', 10 )->andReturn( 10 ); + + WP_Query::$next_found_posts = 40; + + $this->assertEquals( 4, $test->get_max_pages( 'de_DE', Tax::PAGINATION_ARCHIVE ) ); + + $this->assertEquals( array( 'post' ), WP_Query::$last_args['post_type'] ); + $this->assertTrue( WP_Query::$last_args['tax_query'][0]['include_children'] ); + + unset( $GLOBALS['wp_query'] ); + } + + public function test_get_max_pages_falls_back_to_the_queried_term(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + $this->expect_tax_query( 'category' ); + + $term = $this->TermFactory( 42, 8 ); + + Functions\expect( 'ms_is_switched' )->once()->andReturnFalse(); + Functions\expect( 'get_term' )->once()->with( 42, 'category' )->andReturn( $term ); + Functions\expect( 'get_term_children' )->once()->with( 42, 'category' )->andReturn( array() ); + Functions\expect( 'get_option' )->once()->with( 'posts_per_page', 10 )->andReturn( 10 ); + + $this->assertEquals( 1, $test->get_max_pages( 'es_ES', Tax::PAGINATION_ARCHIVE ) ); + + unset( $GLOBALS['wp_query'] ); + } + + public function test_get_max_pages_without_a_translation_in_a_switched_blog(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + $this->expect_tax_query( 'category' ); + + Functions\expect( 'ms_is_switched' )->once()->andReturnTrue(); + Functions\expect( 'get_term' )->never(); + + $this->assertEquals( 0, $test->get_max_pages( 'es_ES', Tax::PAGINATION_ARCHIVE ) ); + + unset( $GLOBALS['wp_query'] ); + } + + public function test_get_max_pages_without_a_taxonomy(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + Functions\expect( 'is_woocommerce' )->once()->andReturn( false ); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', Tax::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_of_a_post_split_by_nextpage(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', Tax::PAGINATION_SINGLE ) ); + } + public function test_get_tax_query(): void { Functions\expect( 'is_woocommerce' )->once()->andReturn( false ); diff --git a/tests/phpunit/Options/TestOptions.php b/tests/phpunit/Options/TestOptions.php index 75ecfc125..84c60d060 100644 --- a/tests/phpunit/Options/TestOptions.php +++ b/tests/phpunit/Options/TestOptions.php @@ -7,6 +7,7 @@ use lloc\Msls\ContentTypes\PostType; use lloc\Msls\Options\Options; use lloc\MslsTests\MslsUnitTestCase; +use lloc\MslsTests\WP_Query; use PHPUnit\Framework\Attributes\DataProvider; final class TestOptions extends MslsUnitTestCase { @@ -19,6 +20,87 @@ private function MslsOptionsFactory(): Options { return new Options(); } + /** + * @return array + */ + public static function max_pages_provider(): array { + return array( + 'the paginated blog index' => array( true, 25, 10, 3 ), + 'an exact number of pages' => array( true, 20, 10, 2 ), + 'a blog without posts' => array( true, 0, 10, 0 ), + 'a request which is not the blog index' => array( false, 25, 10, 0 ), + ); + } + + #[DataProvider( 'max_pages_provider' )] + public function test_get_max_pages( bool $is_home, int $count, int $per_page, int $expected ): void { + $test = $this->MslsOptionsFactory(); + + Functions\when( 'is_home' )->justReturn( $is_home ); + Functions\when( 'is_search' )->justReturn( false ); + Functions\when( 'get_option' )->justReturn( $per_page ); + Functions\when( 'wp_count_posts' )->justReturn( (object) array( 'publish' => $count ) ); + + $this->assertEquals( $expected, $test->get_max_pages( 'de_DE', Options::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_of_a_paginated_search(): void { + $test = $this->MslsOptionsFactory(); + + Functions\when( 'is_home' )->justReturn( false ); + Functions\when( 'is_search' )->justReturn( true ); + Functions\when( 'get_search_query' )->justReturn( 'pasta' ); + Functions\when( 'get_option' )->justReturn( 10 ); + + WP_Query::$next_found_posts = 25; + + $this->assertEquals( 3, $test->get_max_pages( 'de_DE', Options::PAGINATION_ARCHIVE ) ); + $this->assertEquals( 'pasta', WP_Query::$last_args['s'] ); + } + + public function test_get_max_pages_of_a_search_without_a_term(): void { + $test = $this->MslsOptionsFactory(); + + Functions\when( 'is_home' )->justReturn( false ); + Functions\when( 'is_search' )->justReturn( true ); + Functions\when( 'get_search_query' )->justReturn( '' ); + Functions\when( 'get_option' )->justReturn( 10 ); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', Options::PAGINATION_ARCHIVE ) ); + } + + public function test_get_max_pages_of_a_static_front_page(): void { + $test = $this->MslsOptionsFactory(); + + $post = \Mockery::mock( '\WP_Post' ); + $post->post_content = 'OneTwoThree'; + + Functions\when( 'is_front_page' )->justReturn( true ); + Functions\when( 'get_option' )->justReturn( 17 ); + Functions\expect( 'get_post' )->once()->with( 17 )->andReturn( $post ); + + $this->assertEquals( 3, $test->get_max_pages( 'de_DE', Options::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_of_a_blog_without_a_static_front_page(): void { + $test = $this->MslsOptionsFactory(); + + Functions\when( 'is_front_page' )->justReturn( true ); + Functions\when( 'get_option' )->justReturn( 0 ); + Functions\expect( 'get_post' )->never(); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', Options::PAGINATION_SINGLE ) ); + } + + public function test_get_max_pages_of_a_split_post_outside_the_front_page(): void { + $test = $this->MslsOptionsFactory(); + + Functions\when( 'is_front_page' )->justReturn( false ); + Functions\expect( 'get_post' )->never(); + + $this->assertEquals( 0, $test->get_max_pages( 'de_DE', Options::PAGINATION_SINGLE ) ); + } + public function test_is_main_page(): void { Functions\when( 'is_front_page' )->justReturn( true ); diff --git a/tests/phpunit/TestServiceContainer.php b/tests/phpunit/TestServiceContainer.php new file mode 100644 index 000000000..87c5e8d03 --- /dev/null +++ b/tests/phpunit/TestServiceContainer.php @@ -0,0 +1,61 @@ + 42, + 'derived' => function ( ServiceContainer $container ): int { + return $container->get( 'answer' ) + 1; + }, + ) + ); + + $this->assertEquals( 43, $test->get( 'derived' ) ); + } + + public function test_get_returns_a_value_definition_as_it_is(): void { + $test = new ServiceContainer( array( 'flag' => false ) ); + + $this->assertFalse( $test->get( 'flag' ) ); + } + + public function test_get_keeps_the_resolved_entry(): void { + $test = new ServiceContainer( + array( + 'object' => function (): \stdClass { + return new \stdClass(); + }, + ) + ); + + $this->assertSame( $test->get( 'object' ), $test->get( 'object' ) ); + } + + public function test_get_builds_an_undefined_class(): void { + $test = new ServiceContainer(); + + $this->assertInstanceOf( \stdClass::class, $test->get( \stdClass::class ) ); + } + + public function test_get_throws_for_an_unknown_id(): void { + $test = new ServiceContainer(); + + $this->expectException( \RuntimeException::class ); + + $test->get( 'lloc\Msls\ThereIsNoSuchClass' ); + } + + public function test_has(): void { + $test = new ServiceContainer( array( 'flag' => false ) ); + + $this->assertTrue( $test->has( 'flag' ) ); + $this->assertTrue( $test->has( \stdClass::class ) ); + $this->assertFalse( $test->has( 'lloc\Msls\ThereIsNoSuchClass' ) ); + } +} diff --git a/tests/phpunit/WP_Query.php b/tests/phpunit/WP_Query.php new file mode 100644 index 000000000..07d834b83 --- /dev/null +++ b/tests/phpunit/WP_Query.php @@ -0,0 +1,45 @@ + + */ + public static array $last_args = array(); + + /** + * @var array + */ + public $posts = array(); + + /** + * @var int + */ + public $found_posts = 0; + + /** + * @param array $args + */ + public function __construct( $args = array() ) { + self::$last_args = $args; + + $this->found_posts = self::$next_found_posts; + self::$next_found_posts = 0; + } +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 305c471af..0466ccd56 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -3,5 +3,6 @@ class_alias( \lloc\MslsTests\WP_Widget::class, '\WP_Widget' ); class_alias( \lloc\MslsTests\WP_CLI::class, '\WP_CLI' ); class_alias( \lloc\MslsTests\WP_List_Table::class, '\WP_List_Table' ); +class_alias( \lloc\MslsTests\WP_Query::class, '\WP_Query' ); require_once __DIR__ . '/../../includes/deprecated.php'; diff --git a/tests/playwright/fixtures/msls-fixtures.ts b/tests/playwright/fixtures/msls-fixtures.ts index 0c0beed73..3bdb29b74 100644 --- a/tests/playwright/fixtures/msls-fixtures.ts +++ b/tests/playwright/fixtures/msls-fixtures.ts @@ -23,7 +23,10 @@ const SEED_FILE = path.join( export type SeedPost = { id: number; slug: string; link: string }; export type Seed = { posts: Record; + // Root and de are split by , it is not. + paged: Record; body: string; + pagedBodies: Record; }; export function subsiteUrl(slug: SubsiteSlug): string { diff --git a/tests/playwright/setup/global-setup.ts b/tests/playwright/setup/global-setup.ts index 91eebe227..9432f694b 100644 --- a/tests/playwright/setup/global-setup.ts +++ b/tests/playwright/setup/global-setup.ts @@ -18,13 +18,21 @@ const SUBSITES: ReadonlyArray = [ { slug: 'it', title: 'Italian Site', wplang: 'it_IT' }, ]; -const SEED_POST_TITLES: Record = { - '': 'MSLS Demo (en)', - de: 'MSLS Demo (de)', - it: 'MSLS Demo (it)', +const SEED_POST_BODY = '

Demo body. Switcher below:

\n[sc_msls]'; + +// Root and de are split into two pages, it stays on one, so a link which runs out +// of range on the target blog can be asserted as well. +const PAGED_POST_BODIES: Record = { + '': '

Page one.

\n[sc_msls]\n\n

Page two.

\n[sc_msls]', + de: '

Seite eins.

\n[sc_msls]\n\n

Seite zwei.

\n[sc_msls]', + it: '

Pagina una.

\n[sc_msls]', }; -const SEED_POST_BODY = '

Demo body. Switcher below:

\n[sc_msls]'; +// One post per page, so the blog index of every subsite is paginated without +// seeding eleven posts per blog through wp-cli. +const POSTS_PER_PAGE = 1; + +type Created = { id: number; slug: string; link: string }; function wpEnvCli(command: string): string { // wp-env prints status lines on stdout (ℹ Starting…, ✔ Ran…). Strip those @@ -119,11 +127,15 @@ function ensureMultisiteTopology(): void { wpEnvCli( `wp option update permalink_structure '/%postname%/' --url=${urlFor(slug)}` ); + wpEnvCli( + `wp option update posts_per_page ${POSTS_PER_PAGE} --url=${urlFor(slug)}` + ); // Rewrite rules flush — needed after permalink_structure change. wpEnvCli(`wp rewrite flush --hard --url=${urlFor(slug)}`); } wpEnvCli('wp option update permalink_structure \'/%postname%/\''); + wpEnvCli(`wp option update posts_per_page ${POSTS_PER_PAGE}`); wpEnvCli('wp rewrite flush --hard'); wpEnvCli('wp plugin activate multisite-language-switcher --network'); } @@ -174,22 +186,12 @@ async function primeStorageStates(): Promise { } } -function seedTranslationLinkedPosts(): void { - type Created = { id: number; slug: string; link: string }; - const created: Record = {} as Record< - Subsite['slug'], - Created - >; - - // Use wp-cli for post creation — Playwright's request baseURL doesn't - // reliably carry the multisite path prefix into REST calls, so REST POSTs - // all land on the root site. +function deleteSeededPosts(): void { + // Reruns are idempotent: every post of every subsite goes before the sets are + // created again. wp-env's run wraps Docker exec directly (no shell), so $(...) + // won't expand — list the IDs in JS, then pass them to delete as args. for (const sub of SUBSITES) { const url = urlFor(sub.slug); - - // Delete previously seeded demo posts so reruns are idempotent. wp-env's - // run wraps Docker exec directly (no shell), so $(...) won't expand — - // list IDs in JS, then call delete with them as args. const idsRaw = wpEnvCli( `wp post list --post_type=post --field=ID --format=ids --url=${url}` ); @@ -197,20 +199,41 @@ function seedTranslationLinkedPosts(): void { if (existing.length > 0) { wpEnvCli(`wp post delete ${existing.join(' ')} --force --url=${url}`); } + } +} + +function createPostSet( + slugPrefix: string, + titlePrefix: string, + bodies: Record +): Record { + const created: Record = {} as Record< + Subsite['slug'], + Created + >; - const slugForUrl = - sub.slug === '' ? 'msls-demo-en' : `msls-demo-${sub.slug}`; - const titleEsc = SEED_POST_TITLES[sub.slug] + // Use wp-cli for post creation — Playwright's request baseURL doesn't + // reliably carry the multisite path prefix into REST calls, so REST POSTs + // all land on the root site. + for (const sub of SUBSITES) { + const url = urlFor(sub.slug); + const language = sub.slug === '' ? 'en' : sub.slug; + const slugForUrl = `${slugPrefix}-${language}`; + const titleEsc = `${titlePrefix} (${language})` + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + const bodyEsc = bodies[sub.slug] .replace(/\\/g, '\\\\') .replace(/"/g, '\\"'); - const bodyEsc = SEED_POST_BODY.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const idRaw = wpEnvCli( `wp post create --post_type=post --post_status=publish --post_title="${titleEsc}" --post_name=${slugForUrl} --post_content="${bodyEsc}" --porcelain --url=${url}` ); const id = parseInt(idRaw, 10); if (!Number.isFinite(id) || id <= 0) { - throw new Error(`Failed to create seed post on "${sub.slug || 'root'}" (got: ${idRaw})`); + throw new Error( + `Failed to create "${slugForUrl}" on "${sub.slug || 'root'}" (got: ${idRaw})` + ); } created[sub.slug] = { @@ -220,6 +243,10 @@ function seedTranslationLinkedPosts(): void { }; } + return created; +} + +function linkTranslations(created: Record): void { // Translation links are stored per-blog as the WP option `msls_` // — a map of foreign-language codes to the post id on that subsite. See // includes/Options/Options.php (PREFIX 'msls', SEPARATOR '_'). REST/meta @@ -235,18 +262,35 @@ function seedTranslationLinkedPosts(): void { const others: Record = { ...linkMap }; delete others[lang]; - const postId = created[sub.slug].id; - const optionName = `msls_${postId}`; + const optionName = `msls_${created[sub.slug].id}`; const payload = JSON.stringify(others).replace(/'/g, "'\"'\"'"); wpEnvCli( `wp option update '${optionName}' '${payload}' --format=json --autoload=no --url=${urlFor(sub.slug)}` ); } +} + +function seedTranslationLinkedPosts(): void { + deleteSeededPosts(); + + const posts = createPostSet('msls-demo', 'MSLS Demo', { + '': SEED_POST_BODY, + de: SEED_POST_BODY, + it: SEED_POST_BODY, + }); + linkTranslations(posts); + + const paged = createPostSet('msls-paged', 'MSLS Paged', PAGED_POST_BODIES); + linkTranslations(paged); fs.writeFileSync( SEED_FILE, - JSON.stringify({ posts: created, body: SEED_POST_BODY }, null, 2), + JSON.stringify( + { posts, paged, body: SEED_POST_BODY, pagedBodies: PAGED_POST_BODIES }, + null, + 2 + ), 'utf8' ); } diff --git a/tests/playwright/specs/__snapshots__/visual/admin.visual.spec.ts/post-list-root.png b/tests/playwright/specs/__snapshots__/visual/admin.visual.spec.ts/post-list-root.png index 2a013d081..bd0ac3449 100644 Binary files a/tests/playwright/specs/__snapshots__/visual/admin.visual.spec.ts/post-list-root.png and b/tests/playwright/specs/__snapshots__/visual/admin.visual.spec.ts/post-list-root.png differ diff --git a/tests/playwright/specs/frontend/pagination.spec.ts b/tests/playwright/specs/frontend/pagination.spec.ts new file mode 100644 index 000000000..2663ba787 --- /dev/null +++ b/tests/playwright/specs/frontend/pagination.spec.ts @@ -0,0 +1,98 @@ +import type { Page } from '@playwright/test'; + +import { test, expect, subsiteUrl } from '../../fixtures/msls-fixtures'; + +/** + * Reads the rel=alternate head links MSLS prints, keyed by their hreflang. + * + * They run through the same Blog::get_url() as the visible switcher, so they are + * the theme-independent way to assert the link a blog is pointed at. + */ +function alternates(html: string): Record { + const links: Record = {}; + + for (const tag of html.match( + /]+rel=["']alternate["'][^>]*>/gi + ) ?? []) { + const href = /href=["']([^"']+)["']/i.exec(tag)?.[1]; + const lang = /hreflang=["']([^"']+)["']/i.exec(tag)?.[1]; + + if (href && lang) { + links[lang] = href; + } + } + + return links; +} + +async function alternatesOf( + page: Page, + url: string +): Promise> { + const response = await page.goto(url); + expect(response?.ok(), `${url} responds 2xx`).toBe(true); + + return alternates((await response?.text()) ?? ''); +} + +test.describe('MSLS frontend — pagination', () => { + test('page 2 of a split post points at page 2 of the translation', async ({ + page, + seed, + }) => { + const links = await alternatesOf(page, `${seed.paged[''].link}2/`); + + expect(links.en).toBe(`${subsiteUrl('')}/${seed.paged[''].slug}/2/`); + expect(links.de).toBe(`${subsiteUrl('de')}/${seed.paged.de.slug}/2/`); + }); + + test('page 2 falls back to the first page of a shorter translation', async ({ + page, + seed, + }) => { + const links = await alternatesOf(page, `${seed.paged[''].link}2/`); + + // The Italian post has no , so it has no second page. + expect(links.it).toBe(`${subsiteUrl('it')}/${seed.paged.it.slug}/`); + }); + + test('the switcher in the content carries the page as well', async ({ + page, + seed, + }) => { + await page.goto(`${seed.paged[''].link}2/`); + + const anchor = page.locator( + `a[href="${subsiteUrl('de')}/${seed.paged.de.slug}/2/"]` + ); + + await expect(anchor.first()).toBeVisible(); + }); + + test('the first page of a split post is left alone', async ({ + page, + seed, + }) => { + const links = await alternatesOf(page, seed.paged[''].link); + + expect(links.de).toBe(`${subsiteUrl('de')}/${seed.paged.de.slug}/`); + expect(links.it).toBe(`${subsiteUrl('it')}/${seed.paged.it.slug}/`); + }); + + test('page 2 of the blog index points at page 2 of the other blogs', async ({ + page, + }) => { + const links = await alternatesOf(page, '/page/2/'); + + expect(links.en).toBe(`${subsiteUrl('')}/page/2/`); + expect(links.de).toBe(`${subsiteUrl('de')}/page/2/`); + expect(links.it).toBe(`${subsiteUrl('it')}/page/2/`); + }); + + test('the first page of the blog index is left alone', async ({ page }) => { + const links = await alternatesOf(page, '/'); + + expect(links.de).toBe(`${subsiteUrl('de')}/`); + expect(links.it).toBe(`${subsiteUrl('it')}/`); + }); +});