From 884baf35b37c0e5dbd8f5fd732497a53388bd53c Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Thu, 10 Sep 2026 11:18:11 +0200 Subject: [PATCH 1/7] Pagination added --- docs/hooks.md | 73 +++++++ includes/Blog/Blog.php | 8 +- includes/Frontend/Output.php | 9 +- includes/Frontend/Pagination.php | 211 +++++++++++++++++++ includes/Options/Options.php | 45 ++++ includes/Options/Post/Post.php | 29 +++ includes/Options/Query/PostType.php | 16 ++ includes/Options/Query/Query.php | 16 ++ includes/Options/Tax/Tax.php | 25 +++ tests/phpunit/Blog/TestBlog.php | 6 + tests/phpunit/Frontend/TestContentFilter.php | 6 + tests/phpunit/Frontend/TestOutput.php | 6 + tests/phpunit/Frontend/TestPagination.php | 183 ++++++++++++++++ tests/phpunit/Options/Post/TestPost.php | 49 +++++ tests/phpunit/Options/Query/TestAuthor.php | 23 ++ tests/phpunit/Options/Query/TestPostType.php | 26 +++ tests/phpunit/Options/Tax/TestTax.php | 60 ++++++ tests/phpunit/Options/TestOptions.php | 24 +++ 18 files changed, 810 insertions(+), 5 deletions(-) create mode 100644 includes/Frontend/Pagination.php create mode 100644 tests/phpunit/Frontend/TestPagination.php diff --git a/docs/hooks.md b/docs/hooks.md index 4711dfd3..81a38dd0 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -156,6 +156,79 @@ 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. + +`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. 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 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. `WP_Rewrite` is the exception: it is not +switched by `switch_to_blog()`, so the pagination base always comes from the blog +which serves the request. Use `msls_pagination_get` when a blog of the network +translates that base. + +### msls_preserve_pagination + +Filter on whether the page of the current request is kept at all. Return `false` +to restore the pre-3.1 behaviour 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 diff --git a/includes/Blog/Blog.php b/includes/Blog/Blog.php index 3ed39dce..66e74b93 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/Frontend/Output.php b/includes/Frontend/Output.php index 296c0e97..7d135925 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 00000000..a45cd200 --- /dev/null +++ b/includes/Frontend/Pagination.php @@ -0,0 +1,211 @@ + + * + * @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() ) { + return (string) add_query_arg( 'paged', $page, $url ); + } + + return $this->add_path( $url, $this->get_pagination_base() . '/' . $page, Options::PAGINATION_ARCHIVE ); + } + + /** + * 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() ) { + 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, Options::PAGINATION_SINGLE ); + } + + /** + * Appends a path to the link and keeps a query string in place + * + * @param string $url + * @param string $path + * @param string $context + * + * @return string + */ + protected function add_path( string $url, string $path, string $context ): string { + $query = ''; + $pos = strpos( $url, '?' ); + + if ( false !== $pos ) { + $query = substr( $url, $pos ); + $url = substr( $url, 0, $pos ); + } + + return user_trailingslashit( trailingslashit( $url ) . $path, $context ) . $query; + } + + /** + * Checks the permalink structure of the blog the link belongs to + */ + protected function using_permalinks(): bool { + return '' !== (string) get_option( 'permalink_structure', '' ); + } + + /** + * 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 9c2699af..ad3b1e9e 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,48 @@ 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 || ! is_home() ) { + return 0; + } + + return self::posts_to_pages( self::count_published( 'post' ) ); + } + + /** + * 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 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 67b392a5..0ccd12bc 100644 --- a/includes/Options/Post/Post.php +++ b/includes/Options/Post/Post.php @@ -55,4 +55,33 @@ 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; + } + + $post = get_post( $this->get_post_id( $language ) ); + + return is_null( $post ) ? 0 : substr_count( $post->post_content, '' ) + 1; + } + + /** + * 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 { + return $this->has_value( $language ) ? (int) $this->__get( $language ) : $this->get_arg( 0, 0 ); + } } diff --git a/includes/Options/Query/PostType.php b/includes/Options/Query/PostType.php index 324cafa0..e09ab83d 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 d8988959..500819fd 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 e255e6de..53dd2d2c 100644 --- a/includes/Options/Tax/Tax.php +++ b/includes/Options/Tax/Tax.php @@ -115,6 +115,31 @@ 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->has_value( $language ) ? (int) $this->__get( $language ) : $this->get_arg( 0, 0 ); + + if ( empty( $taxonomy ) || empty( $term_id ) ) { + return 0; + } + + $term = get_term( $term_id, $taxonomy ); + + return $term instanceof \WP_Term ? self::posts_to_pages( $term->count ) : 0; + } + /** * Wraps the call to get_term_link * diff --git a/tests/phpunit/Blog/TestBlog.php b/tests/phpunit/Blog/TestBlog.php index 8ee9dee6..de3f1ac7 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 455563bb..9a8299d5 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 5cacf7be..f38cfe23 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 00000000..8d3601f1 --- /dev/null +++ b/tests/phpunit/Frontend/TestPagination.php @@ -0,0 +1,183 @@ +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( 'user_trailingslashit' )->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' )->once()->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' )->once()->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_adds_the_page_of_a_post(): void { + Functions\expect( 'get_option' )->once()->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' )->once()->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' )->once()->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' )->once()->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 ea455007..5a43e120 100644 --- a/tests/phpunit/Options/Post/TestPost.php +++ b/tests/phpunit/Options/Post/TestPost.php @@ -18,6 +18,55 @@ 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_source_post(): void { + $post = \Mockery::mock( '\WP_Post' ); + $post->post_content = 'Not split at all'; + + 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_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 98672d2b..0e2e155c 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 25a99e07..471b73ef 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 4774fb60..c92f72b5 100644 --- a/tests/phpunit/Options/Tax/TestTax.php +++ b/tests/phpunit/Options/Tax/TestTax.php @@ -37,6 +37,66 @@ 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 ) ); + } + + public function test_get_max_pages_from_the_term_count(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + $this->expect_tax_query( 'category' ); + + $term = \Mockery::mock( '\WP_Term' ); + $term->count = 25; + + Functions\expect( 'get_term' )->once()->with( 7, 'category' )->andReturn( $term ); + 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_falls_back_to_the_queried_term(): void { + $test = $this->TaxWithTranslatedTermFactory(); + + $this->expect_tax_query( 'category' ); + + $term = \Mockery::mock( '\WP_Term' ); + $term->count = 8; + + Functions\expect( 'get_term' )->once()->with( 42, 'category' )->andReturn( $term ); + 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_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 75ecfc12..d75e4535 100644 --- a/tests/phpunit/Options/TestOptions.php +++ b/tests/phpunit/Options/TestOptions.php @@ -19,6 +19,30 @@ private function MslsOptionsFactory(): Options { return new Options(); } + /** + * @return array + */ + public static function max_pages_provider(): array { + return array( + 'the paginated blog index' => array( true, Options::PAGINATION_ARCHIVE, 25, 10, 3 ), + 'an exact number of pages' => array( true, Options::PAGINATION_ARCHIVE, 20, 10, 2 ), + 'a blog without posts' => array( true, Options::PAGINATION_ARCHIVE, 0, 10, 0 ), + 'a request which is not the blog index' => array( false, Options::PAGINATION_ARCHIVE, 25, 10, 0 ), + 'a post split by nextpage' => array( true, Options::PAGINATION_SINGLE, 25, 10, 0 ), + ); + } + + #[DataProvider( 'max_pages_provider' )] + public function test_get_max_pages( bool $is_home, string $context, int $count, int $per_page, int $expected ): void { + $test = $this->MslsOptionsFactory(); + + Functions\when( 'is_home' )->justReturn( $is_home ); + 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', $context ) ); + } + public function test_is_main_page(): void { Functions\when( 'is_front_page' )->justReturn( true ); From ec62022ecd2b663255da6b8f10cf5010804778cf Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Wed, 16 Sep 2026 12:39:28 +0200 Subject: [PATCH 2/7] Fixes for Pagination Bugs --- Changelog.md | 6 + MultisiteLanguageSwitcher.php | 4 +- docs/api.md | 42 ++++--- docs/e2e-testing.md | 38 +++---- docs/hooks.md | 107 ++++++++++-------- docs/snippets.md | 27 +++-- includes/Admin/TranslationPicker/Table.php | 2 +- .../Importers/PostMeta/Duplicating.php | 1 + includes/Frontend/Pagination.php | 85 ++++++++++++-- includes/Options/Options.php | 77 ++++++++++++- includes/Options/Post/Post.php | 10 +- includes/Options/Tax/Tax.php | 50 +++++++- languages/default.pot | 2 +- readme.txt | 15 ++- src/msls-widget-block/block.json | 2 +- tests/phpunit/Frontend/TestPagination.php | 44 +++++-- tests/phpunit/Options/Post/TestPost.php | 34 +++++- tests/phpunit/Options/Tax/TestTax.php | 58 +++++++++- tests/phpunit/Options/TestOptions.php | 74 ++++++++++-- tests/phpunit/WP_Query.php | 45 ++++++++ tests/phpunit/bootstrap.php | 1 + 21 files changed, 572 insertions(+), 152 deletions(-) create mode 100644 tests/phpunit/WP_Query.php diff --git a/Changelog.md b/Changelog.md index 1780176b..1f84f444 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,9 @@ +## 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`. +* 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 3f9d93c7..3b52d125 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/docs/api.md b/docs/api.md index 48089b15..04755640 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 32f48378..785ad2e6 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 | | --- | --- | --- | @@ -16,7 +16,7 @@ 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, +commands are listed in [CLAUDE.md](../CLAUDE.md) under *E2E Tests*: `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. @@ -38,22 +38,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 +63,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 +76,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,7 +87,7 @@ 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: `seedTranslationLinkedPosts()` 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 * it re-primes the admin storage states and rewrites @@ -99,7 +99,7 @@ have nothing to do with the run: of that happens (`tests/playwright/setup/global-setup.ts:259`). **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,7 +141,7 @@ 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 @@ -150,6 +150,6 @@ verify a release against the real site. | `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` | | `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:255` | | `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 81a38dd0..8784cbe7 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -2,19 +2,18 @@ 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 them all. 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 +25,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 +44,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 +95,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 +114,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 @@ -145,8 +144,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 @@ -165,27 +164,39 @@ 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. +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. 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`. +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. `WP_Rewrite` is the exception: it is not -switched by `switch_to_blog()`, so the pagination base always comes from the blog -which serves the request. Use `msls_pagination_get` when a blog of the network -translates that base. +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 behaviour and always link to the first page. +to restore the pre-3.1 behavior and always link to the first page. ```php add_filter( 'msls_preserve_pagination', '__return_false' ); @@ -247,7 +258,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. @@ -265,9 +276,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 @@ -292,9 +303,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 @@ -335,8 +346,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( @@ -354,8 +365,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 @@ -376,11 +387,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 @@ -481,7 +492,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 @@ -567,14 +578,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 @@ -615,7 +626,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 b921d998..abfe08d0 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/TranslationPicker/Table.php b/includes/Admin/TranslationPicker/Table.php index cda63706..1935fc21 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/ContentImport/Importers/PostMeta/Duplicating.php b/includes/ContentImport/Importers/PostMeta/Duplicating.php index 45dc1db6..28c30cba 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/Frontend/Pagination.php b/includes/Frontend/Pagination.php index a45cd200..17acff10 100644 --- a/includes/Frontend/Pagination.php +++ b/includes/Frontend/Pagination.php @@ -22,6 +22,34 @@ class Pagination { const MSLS_GET_HOOK = 'msls_pagination_get'; + /** + * Query vars which route a request instead of the path of the link + */ + const QUERY_ROUTE_VARS = array( + 'p', + 'page_id', + 'pagename', + 'name', + 'attachment_id', + 'post_type', + 'cat', + 'category_name', + 'tag', + 'tag_id', + 'taxonomy', + 'term', + 'author', + 'author_name', + 'year', + 'monthnum', + 'day', + 'm', + 's', + 'feed', + 'paged', + 'page', + ); + /** * Page of a paginated archive * @@ -144,11 +172,11 @@ public function get( string $url, OptionsInterface $options, string $language ): * @return string */ protected function add_paged( string $url, int $page ): string { - if ( ! $this->using_permalinks() ) { + if ( ! $this->using_permalinks( $url ) ) { return (string) add_query_arg( 'paged', $page, $url ); } - return $this->add_path( $url, $this->get_pagination_base() . '/' . $page, Options::PAGINATION_ARCHIVE ); + return $this->add_path( $url, $this->get_pagination_base() . '/' . $page ); } /** @@ -160,7 +188,7 @@ protected function add_paged( string $url, int $page ): string { * @return string */ protected function add_page( string $url, int $page ): string { - if ( ! $this->using_permalinks() ) { + if ( ! $this->using_permalinks( $url ) ) { return (string) add_query_arg( 'page', $page, $url ); } @@ -169,7 +197,7 @@ protected function add_page( string $url, int $page ): string { ? $this->get_pagination_base() . '/' . $page : (string) $page; - return $this->add_path( $url, $path, Options::PAGINATION_SINGLE ); + return $this->add_path( $url, $path ); } /** @@ -177,11 +205,10 @@ protected function add_page( string $url, int $page ): string { * * @param string $url * @param string $path - * @param string $context * * @return string */ - protected function add_path( string $url, string $path, string $context ): string { + protected function add_path( string $url, string $path ): string { $query = ''; $pos = strpos( $url, '?' ); @@ -190,14 +217,52 @@ protected function add_path( string $url, string $path, string $context ): strin $url = substr( $url, 0, $pos ); } - return user_trailingslashit( trailingslashit( $url ) . $path, $context ) . $query; + $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 the permalink structure of the blog the link belongs to + * Checks if the page belongs into the path of the link + * + * @param string $url + * + * @return bool */ - protected function using_permalinks(): bool { - return '' !== (string) get_option( 'permalink_structure', '' ); + 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 ) ); } /** diff --git a/includes/Options/Options.php b/includes/Options/Options.php index ad3b1e9e..ff7f93a0 100644 --- a/includes/Options/Options.php +++ b/includes/Options/Options.php @@ -268,11 +268,15 @@ public function get_postlink( $language ) { * @return int */ public function get_max_pages( string $language, string $context = self::PAGINATION_ARCHIVE ): int { - if ( self::PAGINATION_ARCHIVE !== $context || ! is_home() ) { - return 0; + 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 self::posts_to_pages( self::count_published( 'post' ) ); + return is_search() ? self::posts_to_pages( self::count_search_posts() ) : 0; } /** @@ -288,6 +292,73 @@ protected static function count_published( string $post_type ): int { 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 * diff --git a/includes/Options/Post/Post.php b/includes/Options/Post/Post.php index 0ccd12bc..4c3c2a49 100644 --- a/includes/Options/Post/Post.php +++ b/includes/Options/Post/Post.php @@ -69,9 +69,7 @@ public function get_max_pages( string $language, string $context = self::PAGINAT return is_home() ? self::posts_to_pages( self::count_published( 'post' ) ) : 0; } - $post = get_post( $this->get_post_id( $language ) ); - - return is_null( $post ) ? 0 : substr_count( $post->post_content, '' ) + 1; + return self::count_content_pages( $this->get_post_id( $language ) ); } /** @@ -82,6 +80,10 @@ public function get_max_pages( string $language, string $context = self::PAGINAT * @return int */ protected function get_post_id( string $language ): int { - return $this->has_value( $language ) ? (int) $this->__get( $language ) : $this->get_arg( 0, 0 ); + 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/Tax/Tax.php b/includes/Options/Tax/Tax.php index 53dd2d2c..dc1d695d 100644 --- a/includes/Options/Tax/Tax.php +++ b/includes/Options/Tax/Tax.php @@ -129,7 +129,7 @@ public function get_max_pages( string $language, string $context = self::PAGINAT } $taxonomy = $this->get_tax_query(); - $term_id = $this->has_value( $language ) ? (int) $this->__get( $language ) : $this->get_arg( 0, 0 ); + $term_id = $this->get_term_id( $language ); if ( empty( $taxonomy ) || empty( $term_id ) ) { return 0; @@ -137,7 +137,53 @@ public function get_max_pages( string $language, string $context = self::PAGINAT $term = get_term( $term_id, $taxonomy ); - return $term instanceof \WP_Term ? self::posts_to_pages( $term->count ) : 0; + 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, + ), + ), + ) + ); } /** diff --git a/languages/default.pot b/languages/default.pot index 955eb438..f492fc45 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 ab82daf4..18d4da03 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,12 @@ 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. + = 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 +144,13 @@ 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 a paginated request instead of always linking to the first +page of the translated archive. Use `add_filter( 'msls_preserve_pagination', '__return_false' );` +to restore the previous behaviour. Custom `OptionsInterface` implementations are not affected: +the interface is unchanged and such an object simply gets no page appended. + = 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 bbdfe9ef..072c2a69 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/Frontend/TestPagination.php b/tests/phpunit/Frontend/TestPagination.php index 8d3601f1..45334cbd 100644 --- a/tests/phpunit/Frontend/TestPagination.php +++ b/tests/phpunit/Frontend/TestPagination.php @@ -28,11 +28,6 @@ function ( string $string ): string { return rtrim( $string, '/\\' ); } ); - Functions\when( 'user_trailingslashit' )->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; @@ -101,7 +96,7 @@ public function test_get_leaves_an_unpaginated_request_alone(): void { } public function test_get_adds_the_page_of_an_archive(): void { - Functions\expect( 'get_option' )->once()->andReturn( '/%postname%/' ); + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); $test = new Pagination( 3 ); @@ -127,7 +122,7 @@ public function test_get_uses_the_query_string_without_pretty_permalinks(): void } public function test_get_keeps_a_query_string_behind_the_page(): void { - Functions\expect( 'get_option' )->once()->andReturn( '/%postname%/' ); + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); $this->assertEquals( 'https://example.com/main-dishes/page/3/?orderby=title', @@ -135,9 +130,36 @@ public function test_get_keeps_a_query_string_behind_the_page(): void { ); } - public function test_get_adds_the_page_of_a_post(): void { + 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' ) @@ -145,7 +167,7 @@ public function test_get_adds_the_page_of_a_post(): void { } public function test_get_uses_the_pagination_base_for_a_static_front_page(): void { - Functions\expect( 'get_option' )->once()->andReturn( '/%postname%/' ); + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); $this->assertEquals( 'https://example.com/page/2/', @@ -154,7 +176,7 @@ public function test_get_uses_the_pagination_base_for_a_static_front_page(): voi } public function test_get_respects_a_corrected_number_of_pages(): void { - Functions\expect( 'get_option' )->once()->andReturn( '/%postname%/' ); + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); Filters\expectApplied( Pagination::MSLS_MAX_PAGES_HOOK )->once()->andReturn( 9 ); $this->assertEquals( @@ -164,7 +186,7 @@ public function test_get_respects_a_corrected_number_of_pages(): void { } public function test_get_can_be_filtered(): void { - Functions\expect( 'get_option' )->once()->andReturn( '/%postname%/' ); + Functions\expect( 'get_option' )->twice()->andReturn( '/%postname%/' ); Filters\expectApplied( Pagination::MSLS_GET_HOOK )->once()->andReturn( 'https://example.com/filtered/' ); $this->assertEquals( diff --git a/tests/phpunit/Options/Post/TestPost.php b/tests/phpunit/Options/Post/TestPost.php index 5a43e120..9a286e4f 100644 --- a/tests/phpunit/Options/Post/TestPost.php +++ b/tests/phpunit/Options/Post/TestPost.php @@ -29,10 +29,11 @@ public function test_get_max_pages_counts_the_nextpage_quicktags(): void { $this->assertEquals( 3, $test->get_max_pages( 'de_DE', Post::PAGINATION_SINGLE ) ); } - public function test_get_max_pages_falls_back_to_the_source_post(): void { + 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(); @@ -40,6 +41,37 @@ public function test_get_max_pages_falls_back_to_the_source_post(): void { $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(); diff --git a/tests/phpunit/Options/Tax/TestTax.php b/tests/phpunit/Options/Tax/TestTax.php index c92f72b5..0ccf6dfa 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; @@ -51,15 +52,24 @@ private function expect_tax_query( string $taxonomy ): void { $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 = \Mockery::mock( '\WP_Term' ); - $term->count = 25; + $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 ) ); @@ -67,15 +77,42 @@ public function test_get_max_pages_from_the_term_count(): void { 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 = \Mockery::mock( '\WP_Term' ); - $term->count = 8; + $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 ) ); @@ -83,6 +120,19 @@ public function test_get_max_pages_falls_back_to_the_queried_term(): void { 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(); diff --git a/tests/phpunit/Options/TestOptions.php b/tests/phpunit/Options/TestOptions.php index d75e4535..84c60d06 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 { @@ -20,27 +21,84 @@ private function MslsOptionsFactory(): Options { } /** - * @return array + * @return array */ public static function max_pages_provider(): array { return array( - 'the paginated blog index' => array( true, Options::PAGINATION_ARCHIVE, 25, 10, 3 ), - 'an exact number of pages' => array( true, Options::PAGINATION_ARCHIVE, 20, 10, 2 ), - 'a blog without posts' => array( true, Options::PAGINATION_ARCHIVE, 0, 10, 0 ), - 'a request which is not the blog index' => array( false, Options::PAGINATION_ARCHIVE, 25, 10, 0 ), - 'a post split by nextpage' => array( true, Options::PAGINATION_SINGLE, 25, 10, 0 ), + '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, string $context, int $count, int $per_page, int $expected ): void { + 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', $context ) ); + $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 { diff --git a/tests/phpunit/WP_Query.php b/tests/phpunit/WP_Query.php new file mode 100644 index 00000000..07d834b8 --- /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 305c471a..0466ccd5 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'; From 885a6863f68a6b18e8d1c94fbb41e4e75d162e1f Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Wed, 16 Sep 2026 13:36:16 +0200 Subject: [PATCH 3/7] PHP-DI removed --- CLAUDE.md | 6 +- Changelog.md | 3 + assets/css-flags/flags.php | 25 +- assets/flags/flags.php | 25 +- composer.json | 1 - composer.lock | 362 ++---------------- docs/e2e-testing.md | 58 ++- includes/Container.php | 29 +- includes/ContentImport/ImportLogger.php | 2 +- .../ContentImport/Importers/BaseImporter.php | 4 +- includes/ContentImport/Relations.php | 2 +- includes/ServiceContainer.php | 89 +++++ readme.txt | 2 + tests/phpunit/TestServiceContainer.php | 61 +++ tests/playwright/fixtures/msls-fixtures.ts | 3 + tests/playwright/setup/global-setup.ts | 98 +++-- .../admin.visual.spec.ts/post-list-root.png | Bin 13938 -> 19038 bytes .../specs/frontend/pagination.spec.ts | 98 +++++ 18 files changed, 464 insertions(+), 404 deletions(-) create mode 100644 includes/ServiceContainer.php create mode 100644 tests/phpunit/TestServiceContainer.php create mode 100644 tests/playwright/specs/frontend/pagination.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 64209703..0b2fe658 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 1f84f444..da91f33d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,9 @@ * 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 diff --git a/assets/css-flags/flags.php b/assets/css-flags/flags.php index 5a0da669..22382fab 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 fa58c36c..3e8a7ff7 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 de546b96..03c822d5 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 08012b17..3f698a0f 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/e2e-testing.md b/docs/e2e-testing.md index 785ad2e6..a4a36298 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -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*: -`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 @@ -87,16 +125,16 @@ is 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 @@ -148,8 +186,8 @@ verify a release against the real site. | 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/includes/Container.php b/includes/Container.php index 0dfda9e6..e41281d4 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 c510fab4..a5619ec2 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 afacee61..78b67014 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/Relations.php b/includes/ContentImport/Relations.php index e1544ff3..161faad8 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/ServiceContainer.php b/includes/ServiceContainer.php new file mode 100644 index 00000000..3ea8b0d3 --- /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/readme.txt b/readme.txt index 18d4da03..9a2ec503 100644 --- a/readme.txt +++ b/readme.txt @@ -98,6 +98,8 @@ Please visit the [MSLS website](https://msls.co/) or use the [WordPress support * 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. +* 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 = diff --git a/tests/phpunit/TestServiceContainer.php b/tests/phpunit/TestServiceContainer.php new file mode 100644 index 00000000..87c5e8d0 --- /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/playwright/fixtures/msls-fixtures.ts b/tests/playwright/fixtures/msls-fixtures.ts index 0c0beed7..3bdb29b7 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 91eebe22..9432f694 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 2a013d081f6535c34ea3c221569e7e9ce9909307..bd0ac3449712316f948d2fe63493e76f38e2181f 100644 GIT binary patch literal 19038 zcmd?RRa9Kf*X|3+8xlNtaDux_@C5h9T@t);cS~@02->*2TSIVncWvC=*`4J5e|wL! z&)7HLzB%LMs#cFqt?H^;HJ|xg&m>r0P8=Bl4*>=S23b-f!PmZA>8X+fx&Kbv%pr70p+N)y)TT-Ae7p$7_ezd=H1$BN5do zZ{9RFw+ltbEqpzALiO$Y`076W)7r^{#@*0}0B`g0;fO%njvyP|)rH_F^cK*6eqW?s z%w`G({$90myj0_KA^o}d%=}*AZ{zcSvyt<+!>ZaQ$AhZToz2b8);A)Uzx(#F7@3_- zG$NcENBncEfBr#x^KXOK4fc1-(B5+}-az9m>F-H``OL|+-GiLq_*I2BY1h2XZp(RzQuW(aq59>4$xGr^gFk zUW3#9a%jmgCOC>w)XJG<+oF&5t%BkHVw;NXnOLGR7B_xAb)dVh@9 zxxL0ws3TAYZpwmrsDYZ~`c5;Xyym#8#*?rr^{Uu<1PZS}{Q1Z41Z6QCu)zGcb=O3n z<}!5CFsM8!!*Gp&g)+@&3*lOPUbej{*DoflNpMJnUJu9M5klTLYTcoQh5ek-y)u3` zhGgDi-LwTz(`-0?{q-U5=@n^+w3i9oE9L@ky_*riale;FMDL^dhldW=$HxwT1lHT* z+%$m8$INegb!aYuT{7(+oZC6UYs{O=y-irW26}J@g&;9-6(I8~M!5k9Utx^pfs_8A zqGWTCI8?FKMIYtvp+>BfMTIf`mJzTpK4_rd^#t=Y=dVypN0U6oF5P5cnq#}t^fLu@ zBNttDN|v)O(b1|`Sx#OI_X+xo`gg`ul(E+FLn0ysjy=t0>tt(m1neF~6j$qw9zada z&U<5Z9I}FL=QU5G$0h*66E4HsBiu)F`EE8eTzdsdTehbjbg+5 z;T8|bLRRh|v7l=*S>R?QM{y*PfHGnG34Tku=d0*mQvEj3#Ev@=02n>mT5LWf*4cPA?b z#e%hC&$o3ijR(-@jd4WoXrbo#-d;SRLNRYN3E*9U(Q@idaY+G5#z)a;y= zmTG$U`u?(G`@o#~Tp;A1H{b-R+{=}<^7LjK+u4<;L`J<@c};ff3DZ6wFMXC9o$B2i zhugz8|lldXjuG zB|TuJ^#Ij3=?L3f@@X|wT_18B9lRF~{=r&Q7DrF6)x_ZR@C~b0&`nI1kkj`&!wSKs zw+cR?Iqe$#(W0bAqXX^u8^UVqyi=1y$mOun_gIv25YYM?V>0hV0Av47Wctm>gph-S zV+1#urGZKwZgG|(m>284BahBGw?n)!tB>{8>GI5mhNTUPHi4x?6_K6TWOe1-Mw-vPqN>oOp+nOB9w)_idm)MQk%^&IWP2|WCN;~kMxrTCcM+iw`%Bo%JaS6?^~q7A!DbT`?DxwVbgD46 zmsXGX`1t9vqZHKRm39$Q&0wT8F#YVueh7(`{PN+?wgB7BzI_9)WQ|TsPcBG_ypVv| z3NHe=Z?onMHPcIB;T!L*)2gG&dg49Kp_*0B)pib$;ZjyX%`%>VL^*KNv|T zW(6C|;#n>!y^U}IBtXg7pLD~DlJ>LmN)KQ{kSg5fWA%P9INu<8+@08O% z=RJ8b=fzSnZL-Tn@JGaMusv|9%o2^M&vaQ^X~cNBSHk0lxChvYR+9RkEP?AS&OGux zT=#_NZjQkZQ)@G6&46^c5}n4O_7%oV&wPQ`fknzoL1Zcu&~uWDiwoequ1uHBXJBm& zD2`#|PK8p#pLD7rvHM8=?@`{5gJh}3K6lb#-R#-EjMj6A5lRW&E4sk$t!5sppVQDoi_94?eEY9uTdf0_#n@ShY>Jg znt1Fg&LG(@I=}OyDEWjJ%ut`VO}2u1uUWkX{79?${|$bG4vH|CV83YZ@VJ_%T1O$dJY<` zhG^bD$|H1|y_duo6J?3Gnv?>a`b`vV18kqBx;TDbZkTf{L?-@Pr9}&6Yp|KP+KFID z=DZg4em;*kFXer1F+H|uO+-rak@}{TH=4}9Y(Bn}q+jWB+_CyY*ZxkgOQc`0y6R?! zk5;K@i=s^_&V*67l}G>3Z-yZ`oPhHutf2;M`DNK%L;I?$xdHoG9tFms-CQvATnk9g zq|rPl{y~UHaC;5&l3#+Sa1MzP5yS0V#U-?8w04W{!OUlcQ~)4at0#wdIn`nw4el#^ z1#Cjw*$Q{rRL^^>i)=uP=?pHU51i+qSOHmlt~d#D$>yB^Dx@J3`RB(GfmV~4vR1bz zyw^vCX45Siqphz;^cs{)&Ej*CR`NZZA8Xy0i;MuTUl%{g`;Su9klwCEzfut*5^zm+ zxPla`HQGb$D(X#^(7ssTz0NL_>RH|2h!JF`r|0B^o}T#7N3tPpfzSJlbvi$BzoSs= z@mwXUSlX)yG$vZ8{M_iyL@Jf%de2VcT$YN7c2}&tr%2rZRxS8-wR;weI{XTB=Dzzz z!2LL8wbA&>%%M=e?6BZ0_Pqj$WWqs|y!hZli?NF{U9#7+=4y3X%sISt9|JL0-t$lG z9wNb6DpAa$`mxL2PBUTgh!y8G`H&<_t$OV$8-dp@Oy*$~3tFL_E4Va)Z;Uj z4$t==LeAY@YgKU@_Z00apJ?SO`saL+CDO@FLL!+Y6tI&aN59&?la80dxl6O)oyQKni~-tm6)y7w)5VhINP_gvlp=2jfxt1n#>(2#fTQYM1<(% zmKQjN<`O+**AUvCZ?P0Vz#dkzh=`GPTnD^dkIZ~!uEoVvt#|J`c)xioFjKgqmMO1%djs#WODkvb zcD1Y3H&Bb+k~HzYtft#G&VJIk5VSHS6(ZO^kPq-7#agWkB`r*~?@ru!U zOfFfkr`?6D@FC*PP-^v8g`|OL_bcM5Y^~XxFM1N+Zr8rE7d=5G6ZjtWnEz{E%G%c= z(CLon@lqkb3LmZNOXV;?$CShIc-daL*{<>Bg=jBmb;$W7|xc~x`gtJo_|o{8IBz-a;NbUp)d zWWZ))Zqq5LKjIS)@Kvd&X5Z*CAN9ud)gg2prwXoxAt8`GC5>EFem1u5+i|jEL60eu zu`!-n^NGd0BY+MN%#2p70H&R8E|3y4f80IuHbv$%orz&Rje|G9HAH;<;1bdebWGa4 zSZRxVD;;~vOOD4-Jj3nBINj_gqltcHfK|OpH5ZIAK$1>KUzjDtC98*fG(J+LtI{3R zcw)sjR$F53brM_#JdyYPj=Zn0(irElb}3<2_uw~RUJ>3%8n35ZSzCLRd|qzN-Xz6_ z>tWRTrCw26zzW0tPBR4YK}^K!dB=RFAlZow2Pbi76jU8{SJIQTU9&pR)ozXJboR=p z{IF+OYuWv#&8wa12nE@Y3R9Cdj&89$@vZOS$XDhm;1cbgS(`5Z?JAQIarck^uz-P2 z4iYX;aLD|2ho3qbb(17&*deF6z|%8K`?5qST!ewu#$T*yFY}12gIuP$A&|mUDFdvz zZbX=8du7N6PP0_a?C}Gro;oQ}EF?XbyuMyr&qkOwyKpyNY4O|{m_86xF6vt2y?Ary zmnnRnKAQs{fr27;cm55BNkT=HU;EjfhJn5yRmxHQaN`ZkOC!x@uiRzwN|t4Q)R??l z;^UyVRv(ELooY>=9}C$IgXa0^xB*yKu)%v3QxG0uekBot5rER~-e|vbp3FKfQ}&Bd zH>wDlh`)M+;NtGiWjaQ(oG>*sJRA-`Ve-U71oOFPeomc9D~u4Um5Fen?%3x-(WAWe z6Xhlw*WjgXs+*bxcf3h1rvpa8$vB7C)e8A z`kO+fN}z`Xh;8W(~!$nv}0Hv^rp4~Dj8 zwE;%XYUL}xB6NR$3`0T^ME!rOu^M*cssm0R59P`~$H;8|mY3(5^&k_@_F>Kh-UVp7 zi_34QO7Qyk-TIPqWIl{&&$yP#Pl^PJox`RL-zL4JP7dpK4quw)+gKN71c%mN1$sE3 zf^hW>!Wrdac;XE$@dvP~x|-E!7}D4}NV{XM;GCwI($7EtlFn8hSUL~Zno^j0_z3ZS zyeUn7qS}lt<5>s}ZIB9RNP*PgRLazE2B{Vs=xFs(#Edo()2-1LYJ73K4|(1e7M{-n zxtjbQ*zL$aU=BI$EV~LU*+X+HYqGd~QRv=D;j-p)yGBAnlBv%vE-a&B8%W!zetA%`Ws)*bGRIY@wK0gz@n^&^p!WpEc${ijL_m+T0OVAnn?;vFZIsoo4$lVA zZO!j~`IFDp&KC}O!gBBE=xA@<_HvHpT6F1L@|7;`uHr9Aeda3ZdgQx{AW$3UdA)9H zX?7c!mpXZB>;4!`q37;^gL7st2TbRDdFlLYO7Rk+M7Z4tOxwc<*P2mru<8-Tal0hi zGZ#qyMSLa;PtyT>xl_A8Q!+Hz9NPMMrZNWu_RPWp9np_CY5TX`QT=urJEzf*y6b!^(x+E*mVFKztHZW1goaraE$-s+S}CM>u$K6`bjBN2WlZ5mPoiKxe_$GD@3i8Kg(<0ACO#N7skuS zWVZ^u*hDK((wdJUG}{6MA91RM?kL{M^}JN3@cL|R3wQQ-F7%ly9k{Uu_c;s!5^coG ze5*W~x62LAM76tLdvPL$GY{+JD-_D|@IP3_CQryU2ZV@CLl#lYlsrxEBPUnU-(b9K z7BWhG+#!Nk7MyLpNfUQxGpBGil%9Br)@7&tM?ozN_OtPPO(Yzm%wZd6X5<~t4hD1v z0Pt?+IUZT;1`PWiyI4xFU+<-`FkmtXLOTZ6Nk+ag@wn@}NYiyU9`FSF#nY1VZ*OoTONt zH0|Yfb0>f|@~UP_|4qXfi$Q^ZZeu|~N{;e3$$Yd*OtpgHE5Ek1WNNoMh`u7I(Dp>X zLHYA?-0S>}Bg^jC2Q3@kB9`k#W8|lNf zp*&8R`u%=s^!^9bS4iyn-H}x@SB;T#b)9GIs_2;d)Z3J{*0}}p+C1J+QrY$C0@ZPL z`NJy;xja;eXTe_i=3sjEc+Z|EXwP11Qfo&;0hq|iAzf$c0B6iz zA;w`yh;Kjm0yLD#-d83GCx(3Ux++UA&#L+GUbquO65w+G)xKz#5-HA9882@b6UAIseBy=jiM)qb2_i{(#l@>0(a)I%j{ zWqe!3+15A3H%K>S2P1m3S1baaS*4inaLFSO)qOgtk1W1XWFa2 zV5IFb#T{Re#+1tA)*CJ=@+DK&${^>Sd!lj4P&aJDG1JC`5m)p-Uic1Fsu@^EI`MlK+{cn=>^qCKH54nTzDfN z*MlkKqJaci#TAzf;^xmPc|Y0Zj22u)nhje-VNLoId>0^#><7l`5xZ7iQvj~Y$VPjs zvx%c*lX9e*@%n0PfL5kvg96yFCB_PDr`G0_oSUo2Zjd|hbqfWYEp#^LUlh$hwELYJ z5jhE0txH9*u0VtlOQHV~V+EVVg=3tg-B19?V}x<-YlK|Bu_I4*V- z(vRI7VXMbjcGootr^O8WURQ)>R21``C*NhZQegn|V|)clIL6Ikmg1BT&^C^Xh#z25 z&ft!3dovS!({$0r?4U~ENYBGAmHXI1%s7hfbXlY} z#@WGAhDKD&8&2caL<8W)gyrkuOb?++xyG*PK}n!_XIdzrOA1HUb5lmEqSn2JE}wc& z5*kFuN!~y(lJ&Yo6-!$V2Q0znx@VJ#0eQ6zOds$*7b+s$L6w<6MtNOkwF+lKmZ$+e z65M15a`KtyP*T50E$jeQXfN%{g)ePe*cz!+qdxlm$jURY71DtZrzsX2Vp8*UV$gmbnWuBdRi$Tc|em(k{!>c*lI-h|9m{bV6dA zV{L3CV(})5jB;BOFC{MsEgSRCE=BcaeGzycA6vM)(BVUa4U9siXq5a#KY-ri?{RMp z_t&C&B;5+Sz5jV%Njl)yfj0e_KoDF|(_PenX&Ag|;BV5`cZLhRe(0D-YW)!@P7x*+ zD{}d5>X&x(+z-~^@xCnm2GrU{jSZRS)Arp@lX*`I2RB!vKN_?ISHeT;B%ncTk^(5# z#TzRnj5XC&%zcQC^8p)Jq3Y!#rz$m`a@s{@kg@>#$j->-SZJngec{pAV3IZQ)08@2 zcNCRk=3d1pdK{kk<;KWO(<^MhO;CtZtD_fV;`GM$WRprRZ?VKS`+gYTWOq)-$NGn$ zK>B&V^(Hl_qFbxRz%6M~ZDmb^wlNi+3d9U6hPf}SOh?D>rK;BA(-naqAamHO1OWd` z`Lq`Qt&iTy1P+gv&)av=GJ@yK;cAJatlUQM7qi24pdLd`Ghlx(ZHtzMdp_(aS>b z%OPeHKh}Kk?lDGZP$P{uO?=&lZ2loLmB(3{2l^rPiD>e5gM1iifRU zRqL;@UTXCvWcm7tg1zH%ienm8#tUtn?=shyyrU*UMTM%eK2r!aau}SR`LWBNe!ljx z_&T5%FC?YV^@FE#(AiYXi3=lm{&e0}k*0n!(dHZ;W4nCtLh!3%wf=zpIm(HZK+hzf zw-sY5!FllPfh@y*A5NHp)T%SH{)UXQqhu4GOHqDG!Tv;csSv%f_>+SP(JDGdv2Wz}g&#o=-N~xvsYHX~UK@FJ&PZ7mNaU-kk21IFGt&9_Y zjgeO%)?T9g@o)|Hl&O|hvU4@bi70r-+j0*obyhIWbTmEAOMDs$MXm0-s7CCv;n^p? zY?85~qoG^Tk36x*1`Wh7qL>%Xbr7nPZU-nh!0)hGD=T@8QJr=9CR!c5+=dokB+!uZ zJL>?xf5)@tk87}2k7tDnwC1eMM1U5j*Fj*?vo2NZb_344c#Wbo>q{-wupswVV&3vGJR zoA=>iISTCBs=%>KGr4*rp0ea!MEprurMARjhWQJ@(LW^AuuRBWC}yL&vq}m-1d5LE z2k@0Q#e@r*CV(P`>sYGFO*zHB#$6m`AI&w?$nL1!Y!PQ41e-+Duc{B5kS8+xKt&t6{AGIVnUC+ZVP9fHTlU48<*RJrM5+z9K!Pl ztY>EhJmo|snudzdu0v0u%zLw9H#=JTmpTpMZZ|i#DT~i0Yn~ z!K~2Jhqqee@13iAnZl-MXf{YSxK-|6PE^-xg=F7=pxHoSY?lCrqc2qaxFa){>)c6u zr4qrk)ER;vz2@Lt#>J3jUwW z8|Ic2wH2-3V)x?Z?u>mS6wwbI5KA^Ekob8{iRRA-1a_fOXc#P7UPv@ zYSOmDROy^^Kj2}C{t}N>|25{xv3Xj2>G3QUY&c)wI^6KB!1jvNr;s^4qKrga!NKU%eQHO%fD!dB*p!` zX~sry`!CApJX#G2(%4D`L=`w+Nz*F~qJIZ9zts6zc;a%xGFe!FCvBz1res3Z<<9Tp z-&rAb^d|X#-X8}KSfubpwJIaW@S~&xSFm=EAJX3yB}sYsvvodc z6a87K|HW+c-@{V>)3o#doqnJwZD@Gt!=H(Gx9B`!E&g|d!TevG717Ufsnf~_1XW*! zksf5+v27@($>wgC>jN$xGv2qcYKT zmvZ*-tgwnY*4>4-oSJ*(&3rI7CG=6c#eKT!ynE9^iC)9{YoO1Ac)o&5j`UmlQ5?usa3-(yF2K{4 zH?r$#VxdDj+7<9;MyhJm+5;3WR=F2~^O5T+>(QjHNQKcTsHyv)h?Rr{3TUmYthDN_ z9PRA%^+d?^z0_(X(IAJ848I|n^_4(*+M|11T6lxuM-9feXSrHugU($7u9-J@hG{*l zIF@1aJEcw|40n~RIMR>hzR{fc&~0{Udg-? zdMUk8UHs%3*6N2xuD5Se;Yf;S3x2V4!-_wv0Pqhk=kk{+PCs?OW3GF2KXGuW>LmpluU z!n+ifD|`_s%D?)7JPw7U6Kuq@i%r@rxXX#Ek4v@VsGz-U{jd?&9ONPzw;D+eTJGcg z%r;r`lruH5Y*|Du5xwT}$vv5F#6HT)+Ps|;cMV=I1!d??vNg*-p(xd6+@Dy=vbR5m zuP-qWLxVK5!J)lbQ&N4wa=r;ZzJVt;D`h{eG?X67#ELk4ae6GtGsE={pZ3=7xzr>| zo7CmYsHS)zm9FoeFb=F!gM#z5Rcg>>65f|(l8LbzPKk8~w~U%^I4-HsiZbAt^ByKTv^HLBuN^zi zaoJLpnHfQ<#pi0|O8sI|C$!lQap)2%s&St@uV$DHjmfc8;F`6dx-UD)n@C~Y!$0gG z!b0G^cp;6qE<6Q>W-UVFF7$U}iWT~FG9ArA#-C?5j!L^_&5{EB@Ks-MckC5XHqfjE zx1ZIFLI*TDMu45h;)*Fw$U{Jd+2dX&b>g&3n<mkdejD}6*k#HtUTHuC&H_bfL_&_#!p`1OHS%4 zH-V_Voj+v`EgRZLG|2%HhR?G#k?DH}YAToezp%7k`Cd|unhTSKJ$7pR49!DYBicA) zdwP!DFTN?5=>pf=?L@U8bHRZ*wni_Gs+m5|2$4yqi3cDbSk+zxh1AXZYa5a^Y0+#Q z8dbELYhYq2AVLx+dRsinj;rf{Z6Q5Uy5Q+VYU+b|IgP>`kL`U^yuP1J^hNIYMzvKT z)80M04c*AXV-f?VsOCk5c(N8V`@3|`8$dd7VRCk`doWqN*j}uFw`Z7~HR}iU>IGvD zg_^63ioDvPIwEC-yUU3qm(~!)RWjxH5*R+`9N8nOkb@gXbEoc!8Odx8jxu0RqDSGm z%&6Z$iVmn247TT^bm5ml>GR zax2IestZ{q9hy2yBaSBnubU%_SGqqG_S)PzJwkZTpr^r=VtvRjj^@HF>LA1;7tRNw%wC-i*pe*M`Js!C!sck$$M?b*dZAEO3^5 z%`eITHEB!EOG6~2hL@KpP|%gb{R(vj^l%K%%5b$gF?r3E zI76iS3^v2qS1K0e64`J&qnd14iyqQxTUBd(J=0< zCFiE^!1Ropt_|`9%{@fA^`c=xNbh-Y1*q%>U{c%!sfh$8Z;~+;nu5Z5^eA75rVqud zN8oF<@8ODc8K2OgIPKO8D!Nxupu{7D=K9QSGL>Wez+}N%uq2BKhl{FzDY{Mu#t%n< zHc3H*dn-H7MO?3v#wuQ_LCNcEQaR-O=Z7z@zO5;fx|IHJN3Vl>y_Vc^4tB5_y{ris zsE*DDkHob@`eHYQMSlcH58}L3Y-&s_i}p`!=SP$#N@8e9vudvwXna|rng34VuVaY% z%7KOm1?Cy!ai>}V`S>d{t+L{T3yx*Z#6W3aQ@4R-H>p_k*Fo*TM^_5;%B}|}NmCSS z1$qy}dV*R@2v4gi1dZ3{+b29_8Z^zD?8LQB-m;lXAKSV)R^-6GOU^*U#;0R}A~I;0 zEL2gj-6#oI>Cd4@mB(j2%PEXJYY8LlR%cu_Wwb%`h+%op^Ihj_lFx#+l5t6? zrJ%hGiQBw7z^9h<9x2%qUW&r3k3*}R_HSYXrW+X%`LkA4j^*Ebp;o4#I>ceHy5up< zdMs6YJ_@}&)7ZX{CGx$vL58Q9%azH`boHkzR4vy98j)s8@eC1zy-vUCCWg}v$Bxb3IU48p%D4#Dli+^RSrXR*X} zQmizE>l=(vH!(5b&Trshsy4lu(fAw&A1~m5pF@$+q@$lo#s|&+{yhgBbwuhoG{KYh zy4TrnPo(ZkI|h^6|KR=WZuG`56&i;8m)YFKGcA;&-PY<^k)2U89bW#l@v$}e*H8I&yc?e}*M)Yr z7wsz00>wVWjAlwi364^6`PQJBEbU!|c}Q+~`3MB2{oG$eeRUPuI}fpYKe^+!cG-t{ zV^@O(I5g47KX3E$2o52#J$b9tu|i=ChsWZIbMaAd>@}q3Wot*x`fK)1oB86eAN$3R zsRT|jLU*}Z+Y40IVx4rCYPl>Le=tIv2lGPsdB#mcw6<>@U;O>Yc}jnywYI- zTZ@bR(Cx|$`}WEzoe-@NonIW_C$qOs`U`9eWoL3NtbFx^!fcH5{{>14{n z6qg=Pzuf&ZFF)mo$W?gGAef4$DY%Fc%uCy??=+{jX|Ev7f2&IvXTVpg=xK_O_~^-T zmIq4KEJ*>DO9XvX7ER*sNN^*M^^VlMGO^g%#`fefx+LJmEb{`Hs#RxoK;2)Pt)H@^ zd9CewduRYOs!hx{5?sVR7tR{&BXxGbyAZ-k%t3z6v9Ko5K?`VGo>6KXD4 zkI4P!j10iQj4$o;Y(m-U?31g=GHvn|y)5p≠Ob=9G=}Dy3iW2*;~^tUxwkC*Qmb-OA?$G` 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 literal 13938 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 uL;nVEo`_*!US1j&WKW diff --git a/tests/playwright/specs/frontend/pagination.spec.ts b/tests/playwright/specs/frontend/pagination.spec.ts new file mode 100644 index 00000000..2663ba78 --- /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')}/`); + }); +}); From db2d8ab609e8bfb78ce76a406dbf4de9d9733106 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Wed, 16 Sep 2026 17:22:51 +0200 Subject: [PATCH 4/7] Exclude specs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b17f2a92..9a3b4e12 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/ From eb5f202cfd959b157021d3534ea8f790b5211f90 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Thu, 17 Sep 2026 09:37:38 +0200 Subject: [PATCH 5/7] Filters to hide the translation picker --- docs/hooks.md | 38 +++++++++++++++++++ includes/Admin/PostListActions.php | 13 +++++++ includes/Admin/TranslationPicker/Page.php | 14 +++++++ readme.txt | 1 + tests/phpunit/Admin/TestPostListActions.php | 22 +++++++++++ .../Admin/TranslationPicker/TestPage.php | 13 +++++++ 6 files changed, 101 insertions(+) create mode 100644 tests/phpunit/Admin/TestPostListActions.php diff --git a/docs/hooks.md b/docs/hooks.md index 8784cbe7..d63910b0 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -510,6 +510,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 diff --git a/includes/Admin/PostListActions.php b/includes/Admin/PostListActions.php index 8c1783bc..b437f53c 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 5c3344df..85e9e9ca 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/readme.txt b/readme.txt index 9a2ec503..3c061252 100644 --- a/readme.txt +++ b/readme.txt @@ -98,6 +98,7 @@ Please visit the [MSLS website](https://msls.co/) or use the [WordPress support * 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. diff --git a/tests/phpunit/Admin/TestPostListActions.php b/tests/phpunit/Admin/TestPostListActions.php new file mode 100644 index 00000000..ff1c7b1c --- /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 af85aaa0..c6cd1888 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' ) ); + } } From 1ae5a0529a851ec31dc99b3f1b4d40ca5a346b16 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Thu, 17 Sep 2026 10:52:52 +0200 Subject: [PATCH 6/7] Document the two undocumented hooks --- docs/hooks.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/hooks.md b/docs/hooks.md index d63910b0..3ef7a7ba 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -3,7 +3,9 @@ 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_`, so `grep -rn "msls_" -includes/` finds them all. +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 the exact arguments a hook receives, grep for its name in @@ -129,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 @@ -322,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 From 74042ebadc4b446642b63b7ec95337ade6945b60 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Mon, 12 Oct 2026 11:42:59 +0200 Subject: [PATCH 7/7] Readme correction --- readme.txt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/readme.txt b/readme.txt index 3c061252..d7fdff8e 100644 --- a/readme.txt +++ b/readme.txt @@ -149,10 +149,8 @@ The full history is kept in the separate [Changelog](https://github.com/lloc/Mul = 3.1.0 = -The switcher now keeps the page of a paginated request instead of always linking to the first -page of the translated archive. Use `add_filter( 'msls_preserve_pagination', '__return_false' );` -to restore the previous behaviour. Custom `OptionsInterface` implementations are not affected: -the interface is unchanged and such an object simply gets no page appended. +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 =