docs(wpmc S5): implementation plan for URL filters
This commit is contained in:
@@ -0,0 +1,846 @@
|
|||||||
|
# S5 — Global URL filters Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Apply `do_shortcode` to page titles and prepend the current city slug to all same-host URLs generated by WordPress (via `home_url`, `post_link`, ACF), plus fix `rest_url` to preserve the actual HTTP_HOST.
|
||||||
|
|
||||||
|
**Architecture:** Three static classes (`TitleFilter`, `LinkFilter`, `RestUrlFilter`) each with `register()` that wires WP filters. `Helpers` gains `without_url_filters(callable)` + `is_bypassed()` for surgical bypass during plugin-internal URL resolution. All routing/prefix logic is gated by `current_town()` and `main_town_slug()` from S3-S4.
|
||||||
|
|
||||||
|
**Tech Stack:** PHP 8.0+, WordPress 6.0+, ACF Pro, PHPUnit 9.6, Brain Monkey 2.7, Mockery 1.6, Patchwork.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-16-s5-url-filters-design.md`
|
||||||
|
**Beads:** `cp-i17`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working Directory
|
||||||
|
|
||||||
|
```
|
||||||
|
/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city
|
||||||
|
```
|
||||||
|
|
||||||
|
Run shell commands from there (quote paths with Cyrillic/spaces).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
wp-multi-city/
|
||||||
|
├── includes/
|
||||||
|
│ ├── class-plugin.php # MODIFY — boot() adds 3 more registrations
|
||||||
|
│ ├── class-helpers.php # MODIFY — bypass_depth + without_url_filters + reset
|
||||||
|
│ ├── class-title-filter.php # CREATE
|
||||||
|
│ ├── class-link-filter.php # CREATE
|
||||||
|
│ └── class-rest-url-filter.php # CREATE
|
||||||
|
└── tests/
|
||||||
|
├── HelpersTest.php # MODIFY — bypass tests
|
||||||
|
├── TitleFilterTest.php # CREATE
|
||||||
|
├── LinkFilterTest.php # CREATE
|
||||||
|
├── RestUrlFilterTest.php # CREATE
|
||||||
|
└── PluginBootS5Test.php # CREATE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Helpers — bypass counter + without_url_filters
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `includes/class-helpers.php`
|
||||||
|
- Modify: `tests/HelpersTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1.1: RED — append three tests to tests/HelpersTest.php**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function test_is_bypassed_returns_false_by_default(): void
|
||||||
|
{
|
||||||
|
self::assertFalse(Helpers::is_bypassed());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_without_url_filters_returns_callback_value_and_keeps_depth_zero(): void
|
||||||
|
{
|
||||||
|
$result = Helpers::without_url_filters(function () {
|
||||||
|
self::assertTrue(Helpers::is_bypassed());
|
||||||
|
return 'inner-value';
|
||||||
|
});
|
||||||
|
|
||||||
|
self::assertSame('inner-value', $result);
|
||||||
|
self::assertFalse(Helpers::is_bypassed());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_without_url_filters_decrements_depth_on_exception(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Helpers::without_url_filters(function () {
|
||||||
|
throw new \RuntimeException('boom');
|
||||||
|
});
|
||||||
|
self::fail('Expected RuntimeException');
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
self::assertSame('boom', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertFalse(Helpers::is_bypassed());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_without_url_filters_supports_nesting(): void
|
||||||
|
{
|
||||||
|
Helpers::without_url_filters(function () {
|
||||||
|
self::assertTrue(Helpers::is_bypassed());
|
||||||
|
|
||||||
|
Helpers::without_url_filters(function () {
|
||||||
|
self::assertTrue(Helpers::is_bypassed());
|
||||||
|
});
|
||||||
|
|
||||||
|
// After inner exits, outer scope still bypassed.
|
||||||
|
self::assertTrue(Helpers::is_bypassed());
|
||||||
|
});
|
||||||
|
|
||||||
|
self::assertFalse(Helpers::is_bypassed());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reset_cache_clears_bypass_depth(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass(Helpers::class);
|
||||||
|
$prop = $reflection->getProperty('bypass_depth');
|
||||||
|
$prop->setValue(null, 5);
|
||||||
|
|
||||||
|
Helpers::reset_cache();
|
||||||
|
|
||||||
|
self::assertFalse(Helpers::is_bypassed());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit --filter "bypass|without_url_filters"`
|
||||||
|
Expected: errors — `Call to undefined method WPMultiCity\Helpers::is_bypassed`. Paste output.
|
||||||
|
|
||||||
|
- [ ] **Step 1.2: GREEN — extend includes/class-helpers.php**
|
||||||
|
|
||||||
|
Find the existing property block (around the other `private static array $...` declarations). Append:
|
||||||
|
|
||||||
|
```php
|
||||||
|
private static int $bypass_depth = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `reset_cache()`. Final body:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static function reset_cache(): void
|
||||||
|
{
|
||||||
|
self::$current_town_memo = null;
|
||||||
|
self::$current_town_resolved = false;
|
||||||
|
self::$term_cache = [];
|
||||||
|
self::$page_unic_cache = [];
|
||||||
|
self::$bypass_depth = 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Append two new methods:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static function without_url_filters(callable $fn)
|
||||||
|
{
|
||||||
|
self::$bypass_depth++;
|
||||||
|
try {
|
||||||
|
return $fn();
|
||||||
|
} finally {
|
||||||
|
self::$bypass_depth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function is_bypassed(): bool
|
||||||
|
{
|
||||||
|
return self::$bypass_depth > 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit --filter "bypass|without_url_filters"`
|
||||||
|
Expected: OK (5 tests, ≥10 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 1.3: Full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: OK (77 tests, ≥148 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 1.4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add includes/class-helpers.php tests/HelpersTest.php
|
||||||
|
git commit -m "feat(wpmc S5): Helpers::without_url_filters bypass counter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: TitleFilter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/TitleFilterTest.php`
|
||||||
|
- Create: `includes/class-title-filter.php`
|
||||||
|
|
||||||
|
- [ ] **Step 2.1: RED — create tests/TitleFilterTest.php**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity\Tests;
|
||||||
|
|
||||||
|
use WPMultiCity\TitleFilter;
|
||||||
|
|
||||||
|
final class TitleFilterTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_register_attaches_the_title_do_shortcode_at_9999(): void
|
||||||
|
{
|
||||||
|
TitleFilter::register();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
9999,
|
||||||
|
has_filter('the_title', 'do_shortcode'),
|
||||||
|
'TitleFilter::register() must hook the_title → do_shortcode at priority 9999'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_register_attaches_single_cat_title_do_shortcode_at_9999(): void
|
||||||
|
{
|
||||||
|
TitleFilter::register();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
9999,
|
||||||
|
has_filter('single_cat_title', 'do_shortcode'),
|
||||||
|
'TitleFilter::register() must hook single_cat_title → do_shortcode at priority 9999'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/TitleFilterTest.php`
|
||||||
|
Expected: 2 errors — class not found.
|
||||||
|
|
||||||
|
- [ ] **Step 2.2: GREEN — create includes/class-title-filter.php**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity;
|
||||||
|
|
||||||
|
final class TitleFilter
|
||||||
|
{
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
\add_filter('the_title', 'do_shortcode', 9999);
|
||||||
|
\add_filter('single_cat_title', 'do_shortcode', 9999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/TitleFilterTest.php`
|
||||||
|
Expected: OK (2 tests, 2 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 2.3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/TitleFilterTest.php includes/class-title-filter.php
|
||||||
|
git commit -m "feat(wpmc S5): TitleFilter hooks the_title + single_cat_title do_shortcode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: LinkFilter — scaffold + register tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/LinkFilterTest.php`
|
||||||
|
- Create: `includes/class-link-filter.php`
|
||||||
|
|
||||||
|
- [ ] **Step 3.1: RED — create test file with register checks**
|
||||||
|
|
||||||
|
`tests/LinkFilterTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity\Tests;
|
||||||
|
|
||||||
|
use WPMultiCity\LinkFilter;
|
||||||
|
|
||||||
|
final class LinkFilterTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_register_attaches_home_url_filter_at_9999(): void
|
||||||
|
{
|
||||||
|
LinkFilter::register();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
9999,
|
||||||
|
has_filter('home_url', [LinkFilter::class, 'filter_home_url']),
|
||||||
|
'LinkFilter::register() must hook home_url at priority 9999'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_register_attaches_post_link_filter_at_10(): void
|
||||||
|
{
|
||||||
|
LinkFilter::register();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
10,
|
||||||
|
has_filter('post_link', [LinkFilter::class, 'filter_post_link']),
|
||||||
|
'LinkFilter::register() must hook post_link at priority 10'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_register_attaches_acf_load_value_filter_at_9999(): void
|
||||||
|
{
|
||||||
|
LinkFilter::register();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
9999,
|
||||||
|
has_filter('acf/load_value', [LinkFilter::class, 'filter_acf_load_value']),
|
||||||
|
'LinkFilter::register() must hook acf/load_value at priority 9999'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/LinkFilterTest.php`
|
||||||
|
Expected: 3 errors — class not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3.2: GREEN — create includes/class-link-filter.php**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity;
|
||||||
|
|
||||||
|
final class LinkFilter
|
||||||
|
{
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
\add_filter('home_url', [self::class, 'filter_home_url'], 9999, 4);
|
||||||
|
\add_filter('post_link', [self::class, 'filter_post_link'], 10, 3);
|
||||||
|
\add_filter('acf/load_value', [self::class, 'filter_acf_load_value'], 9999, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function filter_home_url(string $url, string $path = '', $orig_scheme = null, $blog_id = null): string
|
||||||
|
{
|
||||||
|
return self::add_town_prefix($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function filter_post_link(string $permalink, $post = null, bool $leavename = false): string
|
||||||
|
{
|
||||||
|
return self::add_town_prefix($permalink);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function filter_acf_load_value($value, $post_id = null, $field = null)
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return self::add_town_prefix($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_town_prefix(string $url): string
|
||||||
|
{
|
||||||
|
// Implementation in Task 4.
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/LinkFilterTest.php`
|
||||||
|
Expected: OK (3 tests, 3 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 3.3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/LinkFilterTest.php includes/class-link-filter.php
|
||||||
|
git commit -m "feat(wpmc S5): LinkFilter scaffold + register 3 URL filters"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: LinkFilter::add_town_prefix — full algorithm
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/LinkFilterTest.php`
|
||||||
|
- Modify: `includes/class-link-filter.php`
|
||||||
|
|
||||||
|
- [ ] **Step 4.1: RED — append 9 behavior tests**
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function set_routing_context(string $town_slug = 'tyumen', string $main = 'spb', string $host = 'site.com'): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_URI'] = '/' . $town_slug . '/';
|
||||||
|
$_SERVER['HTTP_HOST'] = $host;
|
||||||
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => $town_slug]);
|
||||||
|
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) use ($main) {
|
||||||
|
if ($name === 'main_town_slug') return $main;
|
||||||
|
return false; // page_no_rep empty, page_unic empty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_no_change_when_current_town_equals_main(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context('spb', 'spb');
|
||||||
|
|
||||||
|
self::assertSame('/about/', LinkFilter::add_town_prefix('/about/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_no_change_when_current_town_is_null(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_URI'] = '/random/';
|
||||||
|
$_SERVER['HTTP_HOST'] = 'site.com';
|
||||||
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
|
||||||
|
\Brain\Monkey\Functions\when('get_field')->justReturn(false);
|
||||||
|
|
||||||
|
self::assertSame('/about/', LinkFilter::add_town_prefix('/about/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_prepends_town_to_path_only_url(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
self::assertSame('/tyumen/about/', LinkFilter::add_town_prefix('/about/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_skips_path_already_prefixed(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
self::assertSame('/tyumen/about/', LinkFilter::add_town_prefix('/tyumen/about/'));
|
||||||
|
self::assertSame('/tyumen', LinkFilter::add_town_prefix('/tyumen'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_full_url_same_host(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
'https://site.com/tyumen/about/',
|
||||||
|
LinkFilter::add_town_prefix('https://site.com/about/')
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
'https://site.com/tyumen/',
|
||||||
|
LinkFilter::add_town_prefix('https://site.com/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_full_url_already_prefixed_no_double_prefix(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
'https://site.com/tyumen/about/',
|
||||||
|
LinkFilter::add_town_prefix('https://site.com/tyumen/about/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_external_host_no_change(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
'https://external.com/foo/',
|
||||||
|
LinkFilter::add_town_prefix('https://external.com/foo/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_non_http_scheme_no_change(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
self::assertSame('mailto:x@y.com', LinkFilter::add_town_prefix('mailto:x@y.com'));
|
||||||
|
self::assertSame('tel:+79991234567', LinkFilter::add_town_prefix('tel:+79991234567'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_respects_page_no_rep_exclusions(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
||||||
|
$_SERVER['HTTP_HOST'] = 'site.com';
|
||||||
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
||||||
|
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||||
|
if ($name === 'main_town_slug') return 'spb';
|
||||||
|
if ($name === 'page_no_rep') return [['link' => '/privacy/']];
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
self::assertSame('/privacy/', LinkFilter::add_town_prefix('/privacy/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_add_town_prefix_returns_unchanged_when_bypassed(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
$result = \WPMultiCity\Helpers::without_url_filters(function () {
|
||||||
|
return LinkFilter::add_town_prefix('/about/');
|
||||||
|
});
|
||||||
|
|
||||||
|
self::assertSame('/about/', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_filter_acf_load_value_returns_array_unchanged(): void
|
||||||
|
{
|
||||||
|
$this->set_routing_context();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['a', 'b'],
|
||||||
|
LinkFilter::filter_acf_load_value(['a', 'b'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/LinkFilterTest.php`
|
||||||
|
Expected: FAIL — `add_town_prefix` returns input unchanged (stub from Task 3); ~9 of the 11 new tests fail.
|
||||||
|
|
||||||
|
- [ ] **Step 4.2: GREEN — replace add_town_prefix stub with full algorithm**
|
||||||
|
|
||||||
|
Replace the `add_town_prefix()` method in `includes/class-link-filter.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static function add_town_prefix(string $url): string
|
||||||
|
{
|
||||||
|
if (Helpers::is_bypassed()) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$town = Helpers::current_town();
|
||||||
|
if ($town === null) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
$main = Helpers::main_town_slug();
|
||||||
|
if ($main !== null && $town === $main) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
if (!Helpers::is_routable_path($url)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
if ($parsed === false) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path-only URL: no scheme and no host.
|
||||||
|
if (empty($parsed['scheme']) && empty($parsed['host'])) {
|
||||||
|
$path = $parsed['path'] ?? '';
|
||||||
|
if (self::path_already_has_town_prefix($path, $town)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
$fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : '';
|
||||||
|
return '/' . $town . $path . $query . $fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full URL with scheme: only http(s) on our own host.
|
||||||
|
$scheme = strtolower((string) ($parsed['scheme'] ?? ''));
|
||||||
|
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = $parsed['host'] ?? '';
|
||||||
|
$current_host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
if ($host === '' || $host !== $current_host) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $parsed['path'] ?? '/';
|
||||||
|
if (self::path_already_has_town_prefix($path, $town)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
$fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : '';
|
||||||
|
return $scheme . '://' . $host . '/' . $town . $path . $query . $fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function path_already_has_town_prefix(string $path, string $town): bool
|
||||||
|
{
|
||||||
|
return $path === '/' . $town
|
||||||
|
|| str_starts_with($path, '/' . $town . '/');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/LinkFilterTest.php`
|
||||||
|
Expected: OK (14 tests, ≥18 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 4.3: Full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: OK (91 tests, ≥168 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 4.4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/LinkFilterTest.php includes/class-link-filter.php
|
||||||
|
git commit -m "feat(wpmc S5): LinkFilter::add_town_prefix path-only + full URL handling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: RestUrlFilter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/RestUrlFilterTest.php`
|
||||||
|
- Create: `includes/class-rest-url-filter.php`
|
||||||
|
|
||||||
|
- [ ] **Step 5.1: RED — create test file**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity\Tests;
|
||||||
|
|
||||||
|
use WPMultiCity\RestUrlFilter;
|
||||||
|
|
||||||
|
final class RestUrlFilterTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_register_attaches_rest_url_filter(): void
|
||||||
|
{
|
||||||
|
RestUrlFilter::register();
|
||||||
|
|
||||||
|
self::assertNotFalse(
|
||||||
|
has_filter('rest_url', [RestUrlFilter::class, 'preserve_host']),
|
||||||
|
'RestUrlFilter::register() must hook rest_url'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preserve_host_replaces_host_when_http_host_set(): void
|
||||||
|
{
|
||||||
|
$_SERVER['HTTP_HOST'] = 'site.com';
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
'https://site.com/wp-json/wp/v2/posts',
|
||||||
|
RestUrlFilter::preserve_host('http://other.example/wp-json/wp/v2/posts')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preserve_host_returns_url_unchanged_when_http_host_missing(): void
|
||||||
|
{
|
||||||
|
unset($_SERVER['HTTP_HOST']);
|
||||||
|
|
||||||
|
$url = 'https://other.example/wp-json/wp/v2/posts';
|
||||||
|
self::assertSame($url, RestUrlFilter::preserve_host($url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/RestUrlFilterTest.php`
|
||||||
|
Expected: 3 errors — class not found.
|
||||||
|
|
||||||
|
- [ ] **Step 5.2: GREEN — create includes/class-rest-url-filter.php**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity;
|
||||||
|
|
||||||
|
final class RestUrlFilter
|
||||||
|
{
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
\add_filter('rest_url', [self::class, 'preserve_host']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function preserve_host(string $url): string
|
||||||
|
{
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
if ($host === '') {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
$replaced = preg_replace('#^https?://[^/]+#', 'https://' . $host, $url);
|
||||||
|
return is_string($replaced) ? $replaced : $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit tests/RestUrlFilterTest.php`
|
||||||
|
Expected: OK (3 tests, 3 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 5.3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/RestUrlFilterTest.php includes/class-rest-url-filter.php
|
||||||
|
git commit -m "feat(wpmc S5): RestUrlFilter forces HTTPS + preserves HTTP_HOST"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Plugin::boot wires S5 filters
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/PluginBootS5Test.php`
|
||||||
|
- Modify: `includes/class-plugin.php`
|
||||||
|
|
||||||
|
- [ ] **Step 6.1: RED — create tests/PluginBootS5Test.php**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity\Tests;
|
||||||
|
|
||||||
|
use WPMultiCity\Plugin;
|
||||||
|
|
||||||
|
final class PluginBootS5Test extends TestCase
|
||||||
|
{
|
||||||
|
public function test_boot_wires_s5_filter_registrations(): void
|
||||||
|
{
|
||||||
|
if (!class_exists('ACF', false)) {
|
||||||
|
eval('class ACF {}');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plugin = Plugin::instance();
|
||||||
|
$reflection = new \ReflectionClass($plugin);
|
||||||
|
$prop = $reflection->getProperty('booted');
|
||||||
|
$prop->setValue($plugin, false);
|
||||||
|
|
||||||
|
$plugin->boot();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
9999,
|
||||||
|
has_filter('the_title', 'do_shortcode'),
|
||||||
|
'boot() must wire TitleFilter (the_title → do_shortcode @9999)'
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
9999,
|
||||||
|
has_filter('home_url', ['WPMultiCity\\LinkFilter', 'filter_home_url']),
|
||||||
|
'boot() must wire LinkFilter (home_url @9999)'
|
||||||
|
);
|
||||||
|
self::assertNotFalse(
|
||||||
|
has_filter('rest_url', ['WPMultiCity\\RestUrlFilter', 'preserve_host']),
|
||||||
|
'boot() must wire RestUrlFilter'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit --filter test_boot_wires_s5_filter_registrations`
|
||||||
|
Expected: FAIL — the_title / home_url / rest_url filters not wired.
|
||||||
|
|
||||||
|
- [ ] **Step 6.2: GREEN — extend Plugin::boot**
|
||||||
|
|
||||||
|
In `includes/class-plugin.php`, after the existing 8 register calls (`Taxonomy`, `PostType`, `FieldGroup`, `ShortcodeDmr`, `OptionsPage`, `TermMeta`, `Redirect`, `Router`), append:
|
||||||
|
|
||||||
|
```php
|
||||||
|
TitleFilter::register();
|
||||||
|
LinkFilter::register();
|
||||||
|
RestUrlFilter::register();
|
||||||
|
```
|
||||||
|
|
||||||
|
Final boot block becomes 11 registrations.
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpunit --filter test_boot_wires_s5_filter_registrations`
|
||||||
|
Expected: OK (1 test, 3 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 6.3: Full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: OK (95 tests, ≥175 assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 6.4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/PluginBootS5Test.php includes/class-plugin.php
|
||||||
|
git commit -m "feat(wpmc S5): Plugin::boot wires TitleFilter + LinkFilter + RestUrlFilter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: README roadmap
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 7.1: Mark S5 done**
|
||||||
|
|
||||||
|
Find `README.md` S5 row:
|
||||||
|
|
||||||
|
```
|
||||||
|
| S5 | Глобальные фильтры the_title/the_content/the_permalink + rest_url | cp-i17 | open |
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `open` → `done`:
|
||||||
|
|
||||||
|
```
|
||||||
|
| S5 | Глобальные фильтры the_title/the_content/the_permalink + rest_url | cp-i17 | done |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7.2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs(wpmc): mark S5 done in README roadmap"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Final verification
|
||||||
|
|
||||||
|
**Files:** none
|
||||||
|
|
||||||
|
- [ ] **Step 8.1: Full PHPUnit run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: OK (95 tests, ≥175 assertions), exit 0, no risky/warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 8.2: PHP lint each new/modified file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in includes/class-helpers.php includes/class-title-filter.php includes/class-link-filter.php includes/class-rest-url-filter.php includes/class-plugin.php tests/HelpersTest.php tests/TitleFilterTest.php tests/LinkFilterTest.php tests/RestUrlFilterTest.php tests/PluginBootS5Test.php; do
|
||||||
|
php -l "$f"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `No syntax errors detected` × 10.
|
||||||
|
|
||||||
|
- [ ] **Step 8.3: Working tree clean**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `nothing to commit, working tree clean`. Handle any Yandex.Disk conflict copy: `git checkout HEAD -- <file>` + delete `(2).php`.
|
||||||
|
|
||||||
|
- [ ] **Step 8.4: Close beads task**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd close cp-i17 --reason "S5 done: TitleFilter (the_title/single_cat_title do_shortcode), LinkFilter (home_url/post_link/acf-load_value city prefix with same-host + already-prefixed guards + page_no_rep exclusions + bypass), RestUrlFilter (HTTPS+HTTP_HOST), Helpers::without_url_filters bypass counter. Plugin::boot wires 3 new classes. ~20 new Brain Monkey tests."
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: cp-i17 CLOSED. `bd ready` now shows cp-la7 (S6) under cp-cw4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance map (spec → tasks)
|
||||||
|
|
||||||
|
| Spec requirement | Task(s) |
|
||||||
|
|---|---|
|
||||||
|
| `Helpers::without_url_filters` + `is_bypassed` + reset | 1 |
|
||||||
|
| `the_title` + `single_cat_title` → do_shortcode @9999 | 2 |
|
||||||
|
| `home_url` filter @9999 with town prefix | 3, 4 |
|
||||||
|
| `post_link` filter @10 with town prefix | 3, 4 |
|
||||||
|
| `acf/load_value` filter @9999 with town prefix + array passthrough | 3, 4 |
|
||||||
|
| `add_town_prefix` path-only branch | 4 |
|
||||||
|
| `add_town_prefix` full-URL same-host branch | 4 |
|
||||||
|
| `add_town_prefix` already-prefixed guard | 4 |
|
||||||
|
| `add_town_prefix` external host pass-through | 4 |
|
||||||
|
| `add_town_prefix` non-http scheme pass-through (mailto/tel) | 4 |
|
||||||
|
| `add_town_prefix` page_no_rep exclusion | 4 |
|
||||||
|
| `add_town_prefix` bypass via `Helpers::is_bypassed` | 4 |
|
||||||
|
| `RestUrlFilter::preserve_host` HTTPS+HTTP_HOST | 5 |
|
||||||
|
| `Plugin::boot` wires 3 new classes | 6 |
|
||||||
|
| README roadmap | 7 |
|
||||||
|
| Full green suite + lint + bd close | 8 |
|
||||||
Reference in New Issue
Block a user