From 441feea191fd371973f68563751b8d96b4dca842 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sat, 16 May 2026 21:10:31 +0500 Subject: [PATCH] =?UTF-8?q?spec(wpmc=20S5):=20URL=20filters=20design=20?= =?UTF-8?q?=E2=80=94=20town=20prefix=20+=20rest=5Furl=20+=20without=5Furl?= =?UTF-8?q?=5Ffilters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-05-16-s5-url-filters-design.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-s5-url-filters-design.md diff --git a/docs/superpowers/specs/2026-05-16-s5-url-filters-design.md b/docs/superpowers/specs/2026-05-16-s5-url-filters-design.md new file mode 100644 index 0000000..d7846fe --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-s5-url-filters-design.md @@ -0,0 +1,282 @@ +# S5 — Глобальные фильтры `the_title` / URL-prefix / `rest_url` + +- Beads: `cp-i17` (parent epic `cp-cw4`) +- Depends on: `cp-83m` (S4 — URL-роутер, закрыт) +- Blocks: `cp-la7` (S6 — PHPUnit integration tests) +- Дата: 2026-05-16 + +## Цель + +Шаблонные и admin-генерируемые строки/URL'ы автоматически адаптируются под текущий городской контекст: + +1. Шорткоды в заголовках страниц рендерятся (`the_title`, `single_cat_title` → `do_shortcode`). +2. Все WP-генерируемые URL'ы того же сайта получают городской префикс (`home_url`, `post_link`, ACF URL-поля → `/{town}/...`). +3. `rest_url()` использует актуальный `HTTP_HOST` и HTTPS (исправление washanyanya-бага с расхождением хоста сервера и пользовательского). +4. Точечный bypass URL-фильтров (`Helpers::without_url_filters`) для случаев, когда плагин строит ссылку и не должен дважды префиксить — точка расширения для cp-ifg (фикс Redirect). + +## Архитектура + +Три новых класса + расширение `Helpers`: + +``` +includes/ +├── class-title-filter.php ← WPMultiCity\TitleFilter +├── class-link-filter.php ← WPMultiCity\LinkFilter +├── class-rest-url-filter.php ← WPMultiCity\RestUrlFilter +└── class-helpers.php (extend) ← +without_url_filters() + is_bypassed() + bypass_depth state +``` + +Все три класса — статические, с `register()` (вешает хук на init/любое подходящее место) и callback-методами. + +`Plugin::boot()` добавит ещё 3 регистрации (было 8 → станет 11). + +## `TitleFilter` + +Только два хука на встроенную WP-функцию `do_shortcode`: + +```php +final class TitleFilter +{ + public static function register(): void + { + \add_filter('the_title', 'do_shortcode', 9999); + \add_filter('single_cat_title', 'do_shortcode', 9999); + } +} +``` + +Priority 9999 — после плагинов вроде Yoast SEO, чтобы они работали со сданным shortcoded заголовком. Класс не имеет внутренней логики; тесты проверяют только что фильтры зарегистрированы. + +## `LinkFilter` — городской префикс на URL + +### Регистрация + +```php +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); +} +``` + +### Callback-методы + +```php +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); +} +``` + +### `LinkFilter::add_town_prefix(string $url): string` — алгоритм + +``` +1. if Helpers::is_bypassed() return $url; +2. $town = Helpers::current_town(); + $main = Helpers::main_town_slug(); +3. if ($town === null) return $url; +4. if ($main !== null && $town === $main) return $url; +5. if (!Helpers::is_routable_path($url)) return $url; + +6. $parsed = parse_url($url); + if ($parsed === false) return $url; + +7. // Path-only URL (нет scheme и 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; + +8. // Full URL with scheme: + $scheme = strtolower($parsed['scheme'] ?? ''); + if ($scheme !== 'http' && $scheme !== 'https') return $url; + + $host = $parsed['host'] ?? ''; + $current_host = $_SERVER['HTTP_HOST'] ?? ''; + if ($host === '' || $host !== $current_host) return $url; // external + + $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 . '/'); +} +``` + +Поведение: + +| Вход URL (context: town=tyumen, host=site.com) | Выход | +|---|---| +| `/about/` | `/tyumen/about/` | +| `/tyumen/about/` | `/tyumen/about/` (already prefixed) | +| `https://site.com/about/` | `https://site.com/tyumen/about/` | +| `https://site.com/tyumen/about/` | `https://site.com/tyumen/about/` | +| `https://site.com/` | `https://site.com/tyumen/` | +| `https://external.com/about/` | `https://external.com/about/` | +| `mailto:x@y.com` | `mailto:x@y.com` (scheme не http) | +| `/privacy/` (page_no_rep) | `/privacy/` | + +Когда `current_town()` равен `main_town_slug` (или null) — URL не меняется вовсе. + +## `RestUrlFilter` + +```php +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; + } + return (string) preg_replace('#^https?://[^/]+#', 'https://' . $host, $url); +} +``` + +Заменяет хост в любом `rest_url()` на текущий `HTTP_HOST` и форсирует HTTPS. Исправляет случай, когда WP-cli, тестовая среда или PHP-FPM конфигурация выставляют `site_url`/`home_url` хост, отличный от того, на котором сервер реально слушает. + +## `Helpers::without_url_filters()` + `is_bypassed()` + +Counter-based bypass: + +```php +private static int $bypass_depth = 0; + +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; +} +``` + +`reset_cache()` дополнительно обнуляет `$bypass_depth = 0` — защита от leaked состояния, если callback выкинул exception и тест не дошёл до finally. + +Использование (cp-ifg перепишет Redirect используя эту обёртку): + +```php +$canonical = Helpers::without_url_filters(fn() => get_permalink($orig_id)); +// $canonical — без городского префикса, безопасно префиксить вручную +``` + +## Plugin::boot() — изменение + +После S2-S4 регистраций добавить: + +```php +TitleFilter::register(); +LinkFilter::register(); +RestUrlFilter::register(); +``` + +Финальный блок в `boot()`: + +```php +Taxonomy::register(); +PostType::register(); +FieldGroup::register(); +ShortcodeDmr::register(); +OptionsPage::register(); +TermMeta::register(); +Redirect::register(); +Router::register(); +TitleFilter::register(); +LinkFilter::register(); +RestUrlFilter::register(); +``` + +## Тестовый план (~18 новых тестов) + +### TitleFilterTest (2) + +1. `register()` вешает `the_title` filter на priority 9999 с callback `do_shortcode`. +2. `register()` вешает `single_cat_title` filter на priority 9999 с callback `do_shortcode`. + +### LinkFilterTest (9) + +3. `register()` вешает 3 фильтра с корректными priority/args. +4. `add_town_prefix`: town==main → URL без изменений. +5. `add_town_prefix`: town==null → URL без изменений. +6. `add_town_prefix`: path-only `/about/` → `/tyumen/about/`. +7. `add_town_prefix`: path-only уже с префиксом `/tyumen/about/` → без изменений. +8. `add_town_prefix`: full URL same host `https://site.com/about/` → `https://site.com/tyumen/about/`. +9. `add_town_prefix`: external host `https://external.com/foo/` → без изменений. +10. `add_town_prefix`: `mailto:x@y` → без изменений. +11. `add_town_prefix`: путь в page_no_rep → без изменений. +12. `filter_acf_load_value` с массивом → возвращает as-is без вызова add_town_prefix. +13. `is_bypassed()` true → URL без изменений (внутри `without_url_filters`). + +### RestUrlFilterTest (3) + +14. `register()` вешает `rest_url` filter. +15. `preserve_host` с HTTP_HOST='site.com' → заменяет хост на `https://site.com/...`. +16. `preserve_host` без HTTP_HOST → URL без изменений. + +### HelpersTest extension (2) + +17. `without_url_filters($fn)` инкрементит depth, вызывает $fn, декрементит даже при throw, возвращает результат. +18. nested `without_url_filters` корректно обрабатывает вложенность (depth 2 → 1 → 0). +19. `reset_cache()` обнуляет `bypass_depth`. + +### PluginBootS5Test (1) + +20. `Plugin::boot()` вешает `TitleFilter::register` / `LinkFilter::register` / `RestUrlFilter::register` — проверка через `has_filter` для каждого добавленного хука. + +Итого: 90 тестов / ~165 assertions. + +## Acceptance verification + +1. `vendor/bin/phpunit` — 90 тестов зелёные. +2. На /tyumen/ контексте: + - `home_url('/uslugi/')` → `https://site.com/tyumen/uslugi/` + - `home_url('/tyumen/about/')` → `https://site.com/tyumen/about/` (no double-prefix) + - `get_permalink($post_id_of_about)` → `https://site.com/tyumen/about/` (через post_link фильтр) + - ACF link field value `/contact/` → `/tyumen/contact/` +3. `rest_url('wp/v2/posts')` всегда возвращает `https://{HTTP_HOST}/wp-json/wp/v2/posts`. +4. `Helpers::without_url_filters(fn() => home_url('/uslugi/'))` → `https://site.com/uslugi/` (без префикса). + +## Что НЕ входит в S5 + +- `the_content` URL substitution — отложено (admin сам пишет ссылки/использует `[shortcode_dmr]`). +- `wp_nav_menu_items` — handled косвенно через `home_url` фильтр (меню-айтемы WP сами проходят через home_url). +- `the_excerpt`/`wp_title` do_shortcode — out of MVP. +- Yoast SEO `wpseo_title`/`wpseo_metadesc` фильтры — out of MVP, отдельный addon. +- CF7 `wpcf7_form_action_url` фильтр — out of MVP. +- Custom sitemap — out of MVP. + +## Open questions + +Нет. Все решения зафиксированы.