From 3824e28933a2d7ee1294bc812f5d2d152168cb8f Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sat, 16 May 2026 22:29:27 +0500 Subject: [PATCH] docs(wpmc S5): implementation plan for URL filters --- .../plans/2026-05-16-s5-url-filters.md | 846 ++++++++++++++++++ 1 file changed, 846 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-s5-url-filters.md diff --git a/docs/superpowers/plans/2026-05-16-s5-url-filters.md b/docs/superpowers/plans/2026-05-16-s5-url-filters.md new file mode 100644 index 0000000..2def090 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-s5-url-filters.md @@ -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 +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 +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 -- ` + 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 |