Files
wp-multi-city/docs/superpowers/specs/2026-05-13-s4-url-router-design.md

358 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# S4 — URL-роутер `/{city}/{path}/` + `page_unic` + `main_page_alt`
- Beads: `cp-83m` (parent epic `cp-cw4`)
- Depends on: `cp-arq` (S3 — `[shortcode_dmr]` + helpers, закрыт)
- Blocks: `cp-i17` (S5 — глобальные фильтры the_title/the_content/the_permalink + rest_url)
- Дата: 2026-05-13
## Цель
Городской URL-роутинг для мультигородского сайта: URL `/{city-slug}/{path}/` загружает страницу с указанным `path` в контексте города `city-slug`. Реализация повторяет washanyanya legacy-подход — патчит global `$wp_query` на действии `wp` priority 9999, без `add_rewrite_rule`.
Поддерживает:
- индивидуальную главную города (term-meta `main_page_alt`) с fallback на опцию `index_town_alt`
- клоны страниц (repeater `page_unic`: orig → clone для конкретного term'а town)
- 301 redirect для пользователя, открывшего оригинал клонированной страницы в контексте не-главного города
- список путей-исключений из роутинга (`page_no_rep`)
## Архитектура
Четыре новых класса + расширение `Helpers`:
```
includes/
├── class-options-page.php ← WPMultiCity\OptionsPage
├── class-term-meta.php ← WPMultiCity\TermMeta
├── class-router.php ← WPMultiCity\Router
├── class-redirect.php ← WPMultiCity\Redirect
└── class-helpers.php (extend) ← +4 новых метода
```
Все четыре регистрируются в `Plugin::boot()` (становится 8 регистраций суммарно). Каждый класс — статический, с `register()` (вешает хук) и handler-методом.
## ACF data model
### Options page (OptionsPage::register)
```php
acf_add_options_page([
'page_title' => 'WP Multi City',
'menu_title' => 'WP Multi City',
'menu_slug' => 'wp-multi-city',
'capability' => 'manage_options',
'redirect' => false,
]);
```
Field group `group_wpmc_routing` (title "WPMultiCity / Routing", location: options_page == wp-multi-city):
| Field name | Type | Settings |
|---|---|---|
| `main_town_slug` | taxonomy | taxonomy=town, single, return_format=slug, allow_null=1 |
| `index_town_alt` | post_object | post_type=page, return_format=id, allow_null=1, multiple=0 |
| `page_unic` | repeater | layout=table, sub_fields=3 (см. ниже) |
| `page_no_rep` | repeater | layout=table, sub_fields=1 (см. ниже) |
`page_unic` sub_fields:
- `page_old` (post_object, post_type=page, return_format=id, allow_null=0)
- `page_new` (post_object, post_type=page, return_format=id, allow_null=0)
- `town` (taxonomy=town, return_format=id, single, allow_null=0)
`page_no_rep` sub_fields:
- `link` (text, instructions: "Префикс URL, например /privacy/")
ACF group keys: `group_wpmc_routing`, `field_wpmc_main_town_slug`, `field_wpmc_index_town_alt`, `field_wpmc_page_unic`, `field_wpmc_page_unic_page_old`, `field_wpmc_page_unic_page_new`, `field_wpmc_page_unic_town`, `field_wpmc_page_no_rep`, `field_wpmc_page_no_rep_link`.
### Term meta (TermMeta::register)
```php
acf_add_local_field_group([
'key' => 'group_wpmc_town_main_page',
'title' => 'WPMultiCity / Town main page',
'fields' => [[
'key' => 'field_wpmc_main_page_alt',
'label' => 'Главная страница для этого города',
'name' => 'main_page_alt',
'type' => 'post_object',
'post_type' => ['page'],
'return_format' => 'id',
'allow_null' => 1,
'multiple' => 0,
]],
'location' => [[['param' => 'taxonomy', 'operator' => '==', 'value' => 'town']]],
]);
```
## Helpers — новые методы
```php
public static function main_town_slug(): ?string;
public static function is_routable_path(?string $path = null): bool;
public static function page_unic_swap(int $page_id_old, string $town_slug): ?int;
public static function index_town_alt_page_id(): ?int;
```
### `main_town_slug()`
Читает `get_field('main_town_slug', 'option')` (ACF). Возвращает slug или null. Сам зарегистрирован как `add_filter('wpmc_main_town_slug', [Helpers::class, 'main_town_slug'], 5)` — это обеспечивает default в S3 `current_town()` без затирания пользовательских фильтров.
Регистрация фильтра — внутри `OptionsPage::register()` (близко к контексту опций).
### `is_routable_path(?string $path = null)`
- `$path === null` → берёт `$_SERVER['REQUEST_URI']`.
- Читает `get_field('page_no_rep', 'option')` (массив с `link`).
- Если любой `$item['link']` встречается в `$path` через `str_contains` → возвращает false (исключение).
- Иначе → true.
### `page_unic_swap(int $page_id_old, string $town_slug): ?int`
- Получает term по slug (`Helpers::get_term_by_slug`).
- Если term не найден → null.
- Читает `get_field('page_unic', 'option')` (массив с `town`/`page_old`/`page_new`).
- Ищет row, где `(int) $row['town'] === $term->term_id && (int) $row['page_old'] === $page_id_old`.
- Возвращает `(int) $row['page_new']` или null.
Per-request cache по ключу `$page_id_old . '|' . $town_slug`.
### `index_town_alt_page_id(): ?int`
`get_field('index_town_alt', 'option')` → int|null. Возвращает null если пусто или 0.
## Router::route() — алгоритм
Регистрируется через `add_action('wp', [self::class, 'route'], 9999)`.
```
1. if (is_admin()) return.
2. if (defined('DOING_AJAX') && DOING_AJAX) return. // Не трогаем admin-ajax
3. $town_slug = Helpers::current_town();
4. $main = Helpers::main_town_slug();
5. if ($town_slug === null) return. // нет городского контекста
6. if ($main !== null && $town_slug === $main) return. // главный город — patching не нужен
7. $request_uri = $_SERVER['REQUEST_URI'] ?? '/';
8. if (!Helpers::is_routable_path($request_uri)) return.
9. $url_original = self::strip_town_prefix($request_uri, $town_slug);
// /{slug}/about/?foo=1 → /about/?foo=1
10. global $wp_query, $wp_the_query, $post;
11. if (self::is_root($url_original)): // "/" или "/?...query"
$term = Helpers::get_term_by_slug($town_slug);
if ($term === null) return;
$page_id = (int) (\get_field('main_page_alt', 'town_' . $term->term_id) ?? 0);
if (!$page_id) $page_id = Helpers::index_town_alt_page_id() ?? 0;
if (!$page_id) return;
$post = \get_post($page_id);
$wp_query = new \WP_Query(['page_id' => $page_id, 'town' => $town_slug]);
$wp_query->is_page = true;
$wp_query->is_front_page = true;
$wp_the_query = $wp_query;
\status_header(200);
return;
12. // url_original — внутренняя страница:
$page_id = \url_to_postid($url_original);
if (!$page_id) return; // 404 от стандартного WP
$swap = Helpers::page_unic_swap($page_id, $town_slug);
if ($swap !== null) $page_id = $swap;
$post = \get_post($page_id);
$wp_query = new \WP_Query(['page_id' => $page_id, 'town' => $town_slug]);
$wp_query->is_page = true;
$wp_the_query = $wp_query;
\status_header(200);
```
Helper методы `strip_town_prefix` и `is_root` приватные:
```php
private static function strip_town_prefix(string $uri, string $town_slug): string
{
$path = strtok($uri, '?');
$query = (strpos($uri, '?') !== false) ? substr($uri, strpos($uri, '?')) : '';
$new_path = preg_replace('#^/' . preg_quote($town_slug, '#') . '(/|$)#', '/', (string) $path);
return ($new_path ?? '/') . $query;
}
private static function is_root(string $url): bool
{
$path = strtok($url, '?');
return $path === '/' || $path === false || $path === '';
}
```
Также Router регистрирует `query_vars` filter:
```php
add_filter('query_vars', static function (array $vars): array {
$vars[] = 'town';
return $vars;
});
```
Это нужно чтобы `get_query_var('town')` работал в шаблонах (WP стрипит query vars, не объявленные через filter).
## Redirect::handle() — алгоритм
Регистрируется через `add_action('wp', [self::class, 'handle'], 9998)` (до Router).
```
1. if (is_admin() || (defined('DOING_AJAX') && DOING_AJAX)) return.
2. $town_slug = Helpers::current_town();
3. $main = Helpers::main_town_slug();
4. if ($town_slug === null || $main === null || $town_slug === $main) return.
// Только если мы НЕ в городском префиксе URL'а
if (preg_match('#^/' . preg_quote($town_slug, '#') . '(/|$)#', $_SERVER['REQUEST_URI'])) return.
5. global $post;
if (!isset($post->ID)) return.
$current_page_id = (int) $post->ID;
6. // Ищем: текущая страница — клон (page_new) для page_unic в контексте $town_slug?
$page_unic = \get_field('page_unic', 'option');
if (!is_array($page_unic)) return;
$term = Helpers::get_term_by_slug($town_slug);
if ($term === null) return;
foreach ($page_unic as $row):
if ((int) ($row['town'] ?? 0) === $term->term_id
&& (int) ($row['page_new'] ?? 0) === $current_page_id):
$orig_id = (int) ($row['page_old'] ?? 0);
if (!$orig_id) return;
$orig_permalink = \get_permalink($orig_id);
if (!$orig_permalink) return;
$path_only = Helpers::remove_domain_link($orig_permalink);
$redirect_to = '/' . $town_slug . $path_only;
\wp_safe_redirect($redirect_to, 301);
exit;
end
end
```
Логика: если посетитель попал на оригинал-клон-страницу через нестандартный URL (без префикса города), 301 редирект на городской вариант. Поведение voluntary для SEO.
## Plugin::boot() — изменение
После S2-S3 регистраций добавить:
```php
OptionsPage::register();
TermMeta::register();
Redirect::register(); // priority 9998, до Router
Router::register(); // priority 9999
```
## wp-multi-city.php — изменение
`tests/TestCase.php` оставляем без изменений (REQUEST_URI cleanup уже есть). В `tearDown` дополнительно очищаем `$GLOBALS['wp_query']`, `$GLOBALS['wp_the_query']`, `$GLOBALS['post']`.
## tests/bootstrap.php — добавить WP_Query stub
```php
if (!class_exists('WP_Query', false)) {
eval('class WP_Query {
public array $constructor_args = [];
public bool $is_page = false;
public bool $is_front_page = false;
public bool $is_404 = false;
public function __construct($args = []) { $this->constructor_args = is_array($args) ? $args : []; }
public function set_404(): void { $this->is_404 = true; }
public function set($k, $v): void {}
}');
}
```
## TestCase::tearDown — расширить
```php
protected function tearDown(): void
{
unset(
$_SERVER['REQUEST_URI'],
$GLOBALS['wp_query'],
$GLOBALS['wp_the_query'],
$GLOBALS['post']
);
Monkey\tearDown();
parent::tearDown();
}
```
## Тестовый план (~35 тестов)
### OptionsPageTest (4)
1. `register` вешает `acf/init` action.
2. `do_register` вызывает `acf_add_options_page` с key `wp-multi-city`.
3. `do_register` вызывает `acf_add_local_field_group` с group_key `group_wpmc_routing`, наличие 4 fields (main_town_slug, index_town_alt, page_unic, page_no_rep).
4. `register` также вешает фильтр `wpmc_main_town_slug` на `Helpers::main_town_slug` priority 5.
### TermMetaTest (3)
5. `register` вешает `acf/init` action.
6. `do_register` no-op если ACF отсутствует (`function_exists('acf_add_local_field_group')` false).
7. `do_register` вызывает `acf_add_local_field_group` с group_key `group_wpmc_town_main_page`, location=taxonomy==town, field name=main_page_alt.
### HelpersTest extension (6)
8. `main_town_slug()` читает `get_field('main_town_slug', 'option')` и возвращает значение.
9. `main_town_slug()` возвращает null если опция пуста/false.
10. `is_routable_path('/foo/')` без page_no_rep → true.
11. `is_routable_path('/privacy/')` когда page_no_rep содержит '/privacy/' → false.
12. `page_unic_swap(10, 'tyumen')` находит row → возвращает clone id.
13. `page_unic_swap` кэширует результат per-request.
### RouterTest (12)
14. `register` вешает `wp` action priority 9999 + `query_vars` filter.
15. `route()` skip если `is_admin()=true`.
16. `route()` skip если `current_town()=null`.
17. `route()` skip если current_town == main_town_slug.
18. `route()` skip если `is_routable_path()=false`.
19. `route()` для /tyumen/ + main_page_alt term-meta → $wp_query построен с page_id=main_page_alt, town=tyumen, is_front_page=true.
20. `route()` для /tyumen/ без main_page_alt + index_town_alt опция → page_id=index_town_alt.
21. `route()` для /tyumen/ без обоих → не патчит $wp_query.
22. `route()` для /tyumen/about/ → url_to_postid('/about/'), $wp_query с page_id из url_to_postid.
23. `route()` для /tyumen/about/ + page_unic swap → page_id заменён на page_new.
24. `route()` для /tyumen/unknown/ (url_to_postid=0) → не патчит.
25. После `route()` глобал `$wp_query->constructor_args['town']` === current_town_slug (для тестируемости get_query_var).
### RedirectTest (5)
26. `register` вешает `wp` action priority 9998.
27. `handle()` skip когда current_town === main_town_slug.
28. `handle()` skip когда `$post` не установлен.
29. `handle()` 301 redirect на /{town}{permalink_path} когда $post->ID — page_new клон для текущего town и текущий URL не имеет городского префикса.
30. `handle()` skip когда URL уже имеет городской префикс (`/spb/...`).
### PluginBootS4Test (2)
31. `Plugin::boot()` вешает `OptionsPage::do_register` на acf/init.
32. `Plugin::boot()` вешает `TermMeta::do_register` на acf/init, `Router::route` на wp@9999, `Redirect::handle` на wp@9998.
### Reset tests (1)
33. `Helpers::reset_cache()` обнуляет page_unic_swap_cache.
## Acceptance verification (после green-тестов)
1. `vendor/bin/phpunit` — 67 тестов / 110+ assertions, 0 risky/warnings.
2. Активировать плагин на чистом WP + ACF Pro. Создать терм `tyumen`, страницу `about`.
3. Открыть admin → WP Multi City → выставить main_town_slug=`spb`. Добавить page_unic row (page_old=10 about, page_new=20 about-tyumen, town=tyumen).
4. Зайти на `/tyumen/about/` → загружается page id 20 (clone), get_query_var('town')='tyumen'.
5. Зайти на `/tyumen/` (term без main_page_alt, index_town_alt не выставлен) → стандартный WP fallback (вероятно главная или 404).
6. Выставить term-meta main_page_alt=30 → `/tyumen/` грузит page 30 как is_front_page=true.
7. Зайти на `/about-tyumen/` (без префикса) → 301 redirect на `/tyumen/about/` (clone маппинг сработал в Redirect).
## Что НЕ входит в S4
- `home_url` фильтр — добавление городского префикса в WP-генерируемые ссылки → S5 (cp-i17)
- `acf/load_value` фильтр — добавление префикса в URL'ы ACF полей → S5
- `the_title` / `the_content` / `the_permalink` фильтры с `do_shortcode` → S5
- `rest_url` фильтр (HTTP_HOST replacement) → S5
- Кастомный sitemap (`add_sitemap_custom_items`) — out of MVP
- Routing для post types кроме page (нянь, single posts) — washanyanya поддерживает, но не входит в MVP
## Open questions
Нет. Все решения зафиксированы.