Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
.phpunit.cache
.phpunit.result.cache
.vscode/
CLAUDE.md
assets/js/msls-widget-block/
assets/js/msls.js
composer.lock
Expand Down
25 changes: 3 additions & 22 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,5 @@
# AGENTS.md

WordPress plugin providing multilingual support via Multisite - connects content across sites for language switching.

## Structure
- `MultisiteLanguageSwitcher.php` - Plugin bootstrap (do not modify header)
- `includes/` - Core PHP classes (`lloc\Msls\` namespace)
- `src/` - JavaScript source components
- `assets/` - CSS, JS, flags, images
- `tests/` - PHPUnit + Playwright tests
- `vendor/` - Composer dependencies (do not edit)

## Conventions
- WordPress Coding Standards (PHPCS)
- Strict typing (`declare(strict_types=1)`)
- Text domain: `msls`

## CI
PHPStan (strict) | PHPCS | PHPUnit | Playwright | GitHub Actions on PRs

## Do Not
- Edit `vendor/` or `build/` directories
- Modify language files outside localization workflow
- Change plugin header in `MultisiteLanguageSwitcher.php`
See [CLAUDE.md](CLAUDE.md) — it is the single source of truth for agent
instructions in this repository (project overview, commands, architecture,
and conventions).
91 changes: 91 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# CLAUDE.md

This file is the single source of truth for coding agents working in this repository (`AGENTS.md` points here).

## About

Multisite Language Switcher (MSLS) is a WordPress plugin that adds multilingual support to WordPress multisite installations. It connects content (posts, pages, custom post types, taxonomies) across sites in a multisite network for language switching.

## Commands

### Testing
```bash
composer phpunit # Run PHPUnit test suite
composer phpunit -- --filter=TestClassName # Run a single test class
composer phpunit -- --filter=testMethodName # Run a single test method
composer phpunit:clover # Run tests with code coverage (XML)
composer phpunit:html # Run tests with code coverage (HTML)
```

### Static Analysis & Linting
```bash
composer phpstan # PHPStan at level 8
composer phpcs # PHP compatibility check (7.4+)
vendor/bin/phpcs # WordPress coding standards (uses .phpcs.xml.dist)
```

### Building
```bash
npm run build # Build JS (uglify + less + Gutenberg block)
npm run build-msls-block # Build only the Gutenberg block
```

### E2E Tests
```bash
npx playwright test # Run Playwright tests (against msls.co)
npx playwright test --ui # Run with UI
```

### Local Development Environment
```bash
npx wp-env start # Start WordPress multisite via wp-env (PHP 8.3)
npx wp-env stop
```

## Architecture

### Repository Layout
- `MultisiteLanguageSwitcher.php` — plugin bootstrap
- `includes/` — core PHP classes
- `src/` — JavaScript source components
- `assets/` — CSS, JS, flags, images
- `docs/` — developer reference (API, hooks, snippets)
- `tests/` — PHPUnit and Playwright tests

### Namespace & Autoloading
- PSR-4: `lloc\Msls\` maps to `includes/`, split into per-concern sub-namespaces: `Admin\`, `Blog\`, `Cli\`, `Component\`, `ContentImport\`, `ContentTypes\`, `Data\`, `Db\`, `Frontend\`, `Link\`, `Options\`, `Registry\`, `Request\`, `RestApi\`
- PSR-4 (dev): `lloc\MslsTests\` maps to `tests/phpunit/`
- Plugin bootstrap: `MultisiteLanguageSwitcher.php` — defines constants, then on `plugins_loaded` requires `includes/aliases.php`, `includes/deprecated.php`, and `includes/api.php`, builds the PHP-DI container from `config.php`, and calls `lloc\Msls\Plugin::init()` plus `lloc\Msls\Cli\Cli::init()`
- **Backwards-compatibility aliases**: `includes/aliases.php` registers the ~60 pre-3.0 flat class names (`MslsOptions`, `MslsLink`, `MslsPlugin`, …) as `class_alias()` entries for their namespaced replacements. Write new code against the namespaced names; the aliases exist only for third-party consumers

### 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
- **Factory methods**: `Options\Options::create()`, `Options\Tax\Tax::create()`, `Options\Query\Query::create()`, `ContentTypes\ContentTypes::create()` return context-aware instances based on WordPress conditional tags (is_category, is_tag, is_day, etc.)
- **Options hierarchy**: `Options\Options` (base, extends `GetSet`) → `Options\Post\Post` (post translations) / `Options\Tax\Tax` → `Options\Tax\Term` → `Options\Tax\Category` (taxonomy translations) / `Options\Query\Query` → `Author`, `Day`, `Month`, `Year`, `PostType` (archive pages)
- **Link rendering**: `Link\Link` base class with variants (`Link\TextOnly`, `Link\ImageOnly`, `Link\TextImage`) — selected by the display index 0–3 from `Link\Link::get_types()`, controlled by admin settings
- **Content Import**: `ContentImport/` subsystem handles duplicating content across sites with importers for post fields, meta, terms, attachments, and thumbnails
- **REST API / Quick Create**: `RestApi/` exposes the endpoints behind the editor metabox button and the "Add from Translation" submenu (`Admin\TranslationPicker\`)

### Global API Functions
`includes/api.php` exposes the template functions: `msls_the_switcher()`, `msls_get_switcher()`, `msls_get_permalink()`, `msls_get_flag_url()`, `msls_blog_collection()`, etc. Legacy names (`the_msls()`, `get_the_msls()`, …) live in `includes/deprecated.php` and forward to them with a `_deprecated_function()` notice.

### Developer Documentation
`docs/` holds the reference material: `api.md` (public API functions), `hooks.md` (every action and filter), `snippets.md` (integration recipes), `acknowledgements.md` (credits and translators). Keep these in sync when adding or renaming a hook or an API function.

### Test Framework
- PHPUnit 10 with Brain\Monkey for WordPress function mocking
- Patchwork for redefining PHP internals (`filter_input`, `filter_input_array`, `filter_has_var`)
- Base test class: `MslsUnitTestCase` — sets up Monkey, stubs common WP escaping/i18n functions
- Tests mirror the source structure with a `Test` prefix: `includes/Options/Tax/Term.php` → `tests/phpunit/Options/Tax/TestTerm.php`

## CI

GitHub Actions runs PHPStan, PHPCS, PHPUnit, and Playwright on every pull request.

## Conventions

- WordPress Coding Standards enforced via PHPCS (tabs, Yoda conditions, WordPress function spacing)
- All classes use `declare(strict_types=1)`
- Text domain: `multisite-language-switcher` everywhere — in the plugin header and in every `__()` / `esc_html__()` call. Do not use `msls` as a text domain; it is the name of the plugin's option row (`get_option( 'msls' )`)
- Do not modify the plugin header in `MultisiteLanguageSwitcher.php`
- Do not edit `vendor/`, `build/`, `node_modules/`, or language files directly
17 changes: 17 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
## 3.0.0

* Add Quick Create for translations: create the translated post straight from the editor metabox, or pick a source post on the new "Add from Translation" submenu (single and bulk), backed by a REST endpoint and switchable in the settings.
* Add `msls_quick_create_capability` so integrations can override the Quick Create permission checks, plus filters for the post data, the inserted post, the response, the untranslated-posts list, and the mapped taxonomy terms.
* Add filter hooks for the AJAX suggest results of the post and term metaboxes.
* Restructure `lloc\Msls\` into per-concern sub-namespaces (`Admin\`, `Blog\`, `ContentImport\`, `ContentTypes\`, `Frontend\`, `Link\`, `Options\`, `Registry\`, `RestApi\`). Every former flat `Msls*` class name keeps working through the aliases in `includes/aliases.php`.
* Move the public helper functions into `includes/api.php` and make the `$attr` argument of `msls_get_switcher()` optional.
* Add a PHP-DI container for service construction.
* Documentation: add a developer reference under `docs/` (public API, hooks, snippets, acknowledgements) and refresh the class and package diagrams.
* Fix: check authorization on the destination post during content import, and correct the ContentImporter permission and post type checks.
* Fix: do not fall back to `home_url()` for taxonomy and query archives.
* Fix: broken links on the page for the latest posts.
* Fix: several issues in the blog collection.
* i18n: close gaps in the WP-CLI messages and the Quick Create button title.
* Internal: strict typing throughout, PHPStan level 8 clean, `ABSPATH` guards, Plugin Check and PHPCS findings addressed, wp-env setup for local multisite development.
* Maintenance: numerous dependency updates.

## 2.10.1
* Fix: Deprecated function warning pointed to non-existent function.
* Documentation: update README.md code snippets to reflect new function names.
Expand Down
5 changes: 3 additions & 2 deletions docs/acknowledgements.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ banner.
Thanks to all translators for their great work.

* German (de_DE) - [Dennis Ploetner](http://lloc.de/)
* Italian (it_IT) - [Antonella Cucinelli](http://www.freely.de/)
* Italian (it_IT) - [Antonella Cucinelli](http://www.freely.de/it/)
* Dutch (nl_NL) - [Alexandra Kleijn](http://www.buurtaal.de/)
* Brazillian Portuguese (pt_BR) - [Victor](http://www.coolweb.com.br/)
* Brazillian Portuguese (pt_BR) - [Coolweb](http://www.coolweb.com.br/)
* Spanish (es_ES) - [Andreas Breitschopp](http://www.ab-weblog.com/en/)
* French (fr_FR) - [Andreas Breitschopp](http://www.ab-tools.com/en/)
* Russian (ru_RU) - [Andrey Vystavkin](http://j4vk.com/)
Expand All @@ -36,6 +36,7 @@ Thanks to all translators for their great work.
* Arabic (ar) - Mohamed Elwan
* Norwegian (nb_NO) - Ibrahim Qraiqe
* Bulgarian (bg_BG) - [Vencislav Raev](http://www.catblue.net/)
* Mexican Spanish (es_MX) - [Fernando Mata](https://fernandomata.mx/)

You can translate this plugin on [translate.wordpress.org](https://translate.wordpress.org/projects/wp-plugins/multisite-language-switcher/), or if you prefer and have created your own language pack, or have an update of an
existing one, you can [send me](mailto:re@lloc.de) your gettext PO
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ mentioned only for completeness.

## Deprecated functions

The following pre-2.10.1 names live in `includes/deprectated.php`. Each one
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.
Expand Down
45 changes: 31 additions & 14 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ switcher renderer for the entire site or per template.

### msls_output_get

Filter applied to the HTML link for every individual language item before it
joins the output array. Use it to wrap, decorate, or replace the per-language
anchor — for example to add a CSS class, swap in a button element, or
append a flag image only on the current language.
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 —
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
class, swap in a button element, or append a flag image only on the current
language.

### msls_output_get_tags

Expand Down Expand Up @@ -356,6 +360,15 @@ that expects another name shape.

## REST API and Quick Create

### msls_quick_create_capability

Filter on the result of the Quick Create capability check. Alongside the
default decision you get the source post ID (`0` for list-style checks), the
source and target blog IDs, and a `$context` of either `read` (checking access
to the source post) or `create` (checking the right to insert on the target
blog). Use it to let a translator without an account on the source blog mirror
a post into the target blog, or to tighten the default checks.

### msls_quick_create_post_data

Filter on the post data array (title, content, status, post type, …) that
Expand Down Expand Up @@ -444,22 +457,26 @@ applies across every import, not just the current one.

### msls_content_import_{type}_importer

Dynamic filter, with `{type}` being one of `post-fields`, `post-meta`,
`terms`, `post-thumbnail`, `attachments`, etc. Returning an `Importer`
instance here forces the factory to use that importer for the corresponding
content type, bypassing the slug-based selection.
Dynamic filter, with `{type}` being the type of one of the five importer
factories: `post-fields`, `post-meta`, `terms`, `post-thumbnail`, or
`attachments`. Returning an `Importer` instance here forces the factory to use
that importer for the corresponding content type, bypassing the slug-based
selection.

### msls_content_import_{type}_importers_map

Dynamic filter, with `{type}` matching one of the factories. It filters the
map of available importer implementations (slug ⇒ class) for that content
type. Use it to register a new importer flavor alongside the built-ins.
Dynamic filter, with `{type}` being one of the five factory types listed
above. It filters the map of available importer implementations
(slug ⇒ class) for that content type. Use it to register a new importer
flavor alongside the built-ins.

### msls_content_import_{slug}_selected
### msls_content_import_{type}_selected

Dynamic filter that picks which importer slug is "selected" for a given
factory. Use it to programmatically switch between competing importer
implementations — for example based on the post type or destination blog.
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.

### msls_content_import_log_writer

Expand Down
4 changes: 2 additions & 2 deletions includes/ContentImport/ContentImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ public function import_content( ImportCoordinates $import_coordinates, array $po
* @param array $post_fields
* @param ImportCoordinates $import_coordinates
*
* @since TBD
* @since 2.4.0
*/
$post_fields = apply_filters( 'msls_content_import_data_before_import', $post_fields, $import_coordinates );

Expand Down Expand Up @@ -359,7 +359,7 @@ public function import_content( ImportCoordinates $import_coordinates, array $po
* @param ImportLogger $logger
* @param Relations $relations
*
* @since TBD
* @since 2.4.0
*/
do_action( self::MSLS_AFTER_IMPORT_ACTION, $import_coordinates, $logger, $relations ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- constant value is already prefixed with "msls_".

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public function make( ImportCoordinates $import_coordinates ) {
* @param array $map A map of importers in the shape [ <importer-slug> => <importer-class> ]
* @param ImportCoordinates $import_coordinates
*
* @since TBD
* @since 2.4.0
*/
$map = apply_filters( "msls_content_import_{$type}_importers_map", $this->importers_map, $import_coordinates );

Expand Down
12 changes: 6 additions & 6 deletions includes/RestApi/RestApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ private static function apply_capability_filter( bool $default_cap, int $source_
* @param int $target_blog_id Target blog ID.
* @param string $context 'read' when checking the source, 'create' when checking the target.
*
* @since TBD
* @since 3.0.0
*/
return (bool) apply_filters(
'msls_quick_create_capability',
Expand Down Expand Up @@ -299,7 +299,7 @@ public function create_translation( \WP_REST_Request $request ) {
* @param int $source_blog_id The source blog ID.
* @param int $target_blog_id The target blog ID.
*
* @since TBD
* @since 3.0.0
*/
$post_data = apply_filters( 'msls_quick_create_post_data', $post_data, $source_post, $source_blog_id, $target_blog_id );

Expand Down Expand Up @@ -333,7 +333,7 @@ public function create_translation( \WP_REST_Request $request ) {
* @param int $source_blog_id The source blog ID.
* @param int $target_blog_id The target blog ID.
*
* @since TBD
* @since 3.0.0
*/
do_action( 'msls_quick_create_after_insert', $new_post_id, $source_post, $source_blog_id, $target_blog_id );

Expand All @@ -358,7 +358,7 @@ public function create_translation( \WP_REST_Request $request ) {
* @param int $source_blog_id The source blog ID.
* @param int $target_blog_id The target blog ID.
*
* @since TBD
* @since 3.0.0
*/
$response_data = apply_filters(
'msls_quick_create_response',
Expand Down Expand Up @@ -440,7 +440,7 @@ public function list_untranslated_posts( \WP_REST_Request $request ) {
* @param int $target_blog_id Target blog ID.
* @param string $post_type Post type queried.
*
* @since TBD
* @since 3.0.0
*/
$items = apply_filters( 'msls_untranslated_posts', $items, $source_blog_id, $target_blog_id, $post_type );

Expand Down Expand Up @@ -549,7 +549,7 @@ protected function prepare_taxonomies(
* @param int $source_blog_id The source blog ID.
* @param int $target_blog_id The target blog ID.
*
* @since TBD
* @since 3.0.0
*/
$post_data['_msls_tax_input'] = apply_filters(
'msls_quick_create_tax_input',
Expand Down