spec(wpmc S4): URL router design — legacy wp_query patch + ACF page_unic + 301
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
# 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
|
||||
|
||||
Нет. Все решения зафиксированы.
|
||||
Reference in New Issue
Block a user