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

16 KiB
Raw Permalink Blame History

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)

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)

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 — новые методы

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 приватные:

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:

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 регистраций добавить:

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

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 — расширить

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)

  1. register вешает acf/init action.
  2. do_register no-op если ACF отсутствует (function_exists('acf_add_local_field_group') false).
  3. 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)

  1. main_town_slug() читает get_field('main_town_slug', 'option') и возвращает значение.
  2. main_town_slug() возвращает null если опция пуста/false.
  3. is_routable_path('/foo/') без page_no_rep → true.
  4. is_routable_path('/privacy/') когда page_no_rep содержит '/privacy/' → false.
  5. page_unic_swap(10, 'tyumen') находит row → возвращает clone id.
  6. page_unic_swap кэширует результат per-request.

RouterTest (12)

  1. register вешает wp action priority 9999 + query_vars filter.
  2. route() skip если is_admin()=true.
  3. route() skip если current_town()=null.
  4. route() skip если current_town == main_town_slug.
  5. route() skip если is_routable_path()=false.
  6. route() для /tyumen/ + main_page_alt term-meta → $wp_query построен с page_id=main_page_alt, town=tyumen, is_front_page=true.
  7. route() для /tyumen/ без main_page_alt + index_town_alt опция → page_id=index_town_alt.
  8. route() для /tyumen/ без обоих → не патчит $wp_query.
  9. route() для /tyumen/about/ → url_to_postid('/about/'), $wp_query с page_id из url_to_postid.
  10. route() для /tyumen/about/ + page_unic swap → page_id заменён на page_new.
  11. route() для /tyumen/unknown/ (url_to_postid=0) → не патчит.
  12. После route() глобал $wp_query->constructor_args['town'] === current_town_slug (для тестируемости get_query_var).

RedirectTest (5)

  1. register вешает wp action priority 9998.
  2. handle() skip когда current_town === main_town_slug.
  3. handle() skip когда $post не установлен.
  4. handle() 301 redirect на /{town}{permalink_path} когда $post->ID — page_new клон для текущего town и текущий URL не имеет городского префикса.
  5. handle() skip когда URL уже имеет городской префикс (/spb/...).

PluginBootS4Test (2)

  1. Plugin::boot() вешает OptionsPage::do_register на acf/init.
  2. Plugin::boot() вешает TermMeta::do_register на acf/init, Router::route на wp@9999, Redirect::handle на wp@9998.

Reset tests (1)

  1. 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

Нет. Все решения зафиксированы.