spec(wpmc S5): URL filters design — town prefix + rest_url + without_url_filters
This commit is contained in:
@@ -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
|
||||
|
||||
Нет. Все решения зафиксированы.
|
||||
Reference in New Issue
Block a user