# Redirect 1.1.0 — washanyanya-style reverse-slug lookup на is_404 - Beads: `cp-ifg` (discovered-from `cp-83m` S4) - Gitea issue: - Дата: 2026-05-19 ## Цель Заменить текущую семантику `Redirect::handle()` на washanyanya-style reverse-slug lookup. Старый handler триггерится только когда клон опубликован под собственным URL — edge-case, нерелевантный для production. Washanyanya-сценарий: клоны хранятся как `private` posts без собственного публичного URL; защита от утечки private URL делается через redirect на канонический городской. Версия — **1.1.0** (minor bump: внутреннее breaking change семантики, внешне расширение функционала). ## Архитектура `Redirect::handle()` — полное переписывание. Старая логика (триггер на `$post->ID === page_new`) удаляется. Новая: ``` template_redirect@1 → if !is_404() return → extract last URL segment as slug candidate → get_posts(name=$slug, post_type='page', status=[private,publish]) → match candidate.ID against page_unic[i].page_new → matched: 301 → ///; exit ``` Hook переезжает с `wp@9998` на `template_redirect@1` — стандартный WP hook для redirect-логики, после resolve query (`is_404` гарантированно определён), раньше SEO-плагинов. ## Артефакты ``` wp-multi-city/ ├── includes/class-redirect.php ← MODIFY: полное переписывание handle() ├── includes/class-plugin.php ← NO CHANGE: register() сам не меняется ├── tests/RedirectTest.php ← REWRITE: 13 тестов под новую семантику ├── tests/PluginBootS4Test.php ← MODIFY: hook check template_redirect@1 (was wp@9998) ├── readme.txt ← MODIFY: + = 1.1.0 = changelog ├── CHANGELOG.md ← MODIFY: + ## [1.1.0] └── README.md ← MODIFY: roadmap mention 1.1.0 (Redirect semantics extended) ``` ## Алгоритм `Redirect::handle()` ```php public static function register(): void { \add_action('template_redirect', [self::class, 'handle'], 1); } public static function handle(): void { if (\is_admin() || (\defined('DOING_AJAX') && \DOING_AJAX)) { return; } if (!\is_404()) { return; } $main = Helpers::main_town_slug(); if ($main === null) { return; } $slug = self::extract_last_segment($_SERVER['REQUEST_URI'] ?? '/'); if ($slug === null) { return; } $candidates = \get_posts([ 'name' => $slug, 'post_type' => 'page', 'post_status' => ['private', 'publish'], 'posts_per_page' => 1, 'no_found_rows' => true, 'suppress_filters' => false, ]); if (empty($candidates)) { return; } $candidate_id = (int) $candidates[0]->ID; if (!\function_exists('get_field')) { return; } $rows = \get_field('page_unic', 'option'); if (!is_array($rows)) { return; } foreach ($rows as $row) { if (!is_array($row)) { continue; } if ((int) ($row['page_new'] ?? 0) !== $candidate_id) { continue; } $town_term_id = (int) ($row['town'] ?? 0); $orig_id = (int) ($row['page_old'] ?? 0); if (!$town_term_id || !$orig_id) { continue; } $term = \get_term_by('id', $town_term_id, 'town'); if (!($term instanceof \WP_Term) || $term->slug === '' || $term->slug === $main) { continue; } $orig_permalink = Helpers::without_url_filters( static fn () => \get_permalink($orig_id) ); if (!is_string($orig_permalink) || $orig_permalink === '') { return; } $path_only = Helpers::remove_domain_link($orig_permalink); $redirect_to = '/' . $term->slug . $path_only; \wp_safe_redirect($redirect_to, 301); exit; } } private static function extract_last_segment(string $uri): ?string { $path = strtok($uri, '?'); if ($path === false) { return null; } $parts = array_values(array_filter( explode('/', $path), static fn (string $p) => $p !== '' )); return end($parts) ?: null; } ``` `Helpers::without_url_filters` обёртка вокруг `get_permalink` нужна чтобы S5 LinkFilter не добавил town prefix повторно (защита от `/tyumen/tyumen/orig/`). ## Edge cases (явные) | Условие | Поведение | |---|---| | `is_admin()` / `DOING_AJAX` | bail | | `!is_404()` | bail — обычная страница, не наш кейс | | `main_town_slug = null` | bail — нет canonical базы | | URI `/` или пустой | bail — нет slug для lookup | | `get_posts` returns `[]` | bail — slug не существует | | Post найден, но не в `page_unic` | bail — чей-то совпадающий slug, не клон | | `page_unic` пустой/null | bail | | Matched row, но `town == main_town_slug` | bail — защита двойного префикса main URL | | `get_permalink($orig_id)` пустой | bail — broken config | ## Тестовый план — `tests/RedirectTest.php` Старые тесты удаляются полностью. Новые 13 тестов: ### Bail-out (6 тестов) 1. `test_bail_when_is_admin` — `is_admin = true`, redirect не вызывается. 2. `test_bail_when_doing_ajax` — `DOING_AJAX` true. 3. `test_bail_when_not_404` — `is_404 = false`. 4. `test_bail_when_main_town_slug_null` — `get_field('main_town_slug')` returns null. 5. `test_bail_when_request_uri_root` — `$_SERVER['REQUEST_URI'] = '/'`. 6. `test_bail_when_no_post_with_slug` — `get_posts` returns `[]`. ### Lookup misses (3 теста) 7. `test_bail_when_post_not_in_page_unic` — slug найден, но `post.ID` не матчит ни одну row. 8. `test_bail_when_page_unic_empty` — `get_field('page_unic')` returns `null`/`[]`. 9. `test_bail_when_matched_town_is_main` — page_unic matched, но `term->slug === main_town_slug`. ### Successful redirect (3 теста) 10. `test_redirect_for_private_clone` — private post по slug, matched, `wp_safe_redirect('/tyumen/tarif/', 301)`. 11. `test_redirect_for_published_clone_with_own_url` — published post (статус `publish` тоже в lookup), редирект работает идентично. 12. `test_redirect_strips_domain_from_get_permalink` — `get_permalink` returns `https://site.com/tarif/` → redirect target `/tyumen/tarif/`. ### Wrapping (1 тест) 13. `test_get_permalink_called_inside_without_url_filters` — verify bypass депти инкрементится перед `get_permalink` и декрементится после. ### `tests/PluginBootS4Test.php` обновление Найти существующий ассерт про `has_action('wp', [Redirect::class, 'handle'])` или аналог с priority 9998 — заменить на `template_redirect` priority 1. **Итого:** 109 + 13 (новый RedirectTest) - 10/11 (старые RedirectTest удаляются) = ~111-112 тестов. Точный delta зависит от текущего числа RedirectTest assertions; цель — ВСЕ зелёные. ## readme.txt + CHANGELOG.md + README.md `readme.txt` — добавить блок перед `= 1.0.1 =`: ``` = 1.1.0 = * Change: `Redirect::handle()` переписан на washanyanya-style. Триггерится на `is_404()`, ищет private/publish page по URL slug, матчит с page_unic, делает 301 на канонический `/{city}/{orig-slug}/`. Старая семантика (триггер по `$post->ID === page_new`) удалена — она покрывала только редкий edge-кейс published clone with own URL. * Change: Redirect handler hook переехал с `wp` priority 9998 на `template_redirect` priority 1 (стандарт WP для redirects). ``` `CHANGELOG.md` — добавить секцию вверху (после title): ```markdown ## [1.1.0] — 2026-05-19 ### Changed - **BREAKING (internal):** `Redirect::handle()` переписан на washanyanya-style. Триггер: `template_redirect` @ priority 1 + `is_404()`. Reverse slug lookup: `get_posts(name=$slug, post_type=page, status=[private,publish])`, затем match в `page_unic[i].page_new`. 301 на `/{city}/{orig-canonical-path}/`. - Удалена старая семантика (триггер по `$post->ID === page_new`). ``` `README.md` — roadmap не нужно менять (S1-S7 status сохраняется). Опционально упомянуть 1.1.0 в Hook API под Redirect. ## Acceptance verification 1. `vendor/bin/phpunit` — все green (~112 tests). 2. PHP lint: `find includes -name '*.php' -not -path '*/lib/*' -exec php -l {} \;` — clean. 3. Smoke в `ural-dez.ru` wp-env: - Создать `tarif` (page, public, ID=N1). - Создать `tarif-tyumen` (page, **private**, ID=N2). - В Options Page → page_unic row: `page_old=N1`, `page_new=N2`, `town=tyumen`. - `curl -I http://localhost:8888/tarif-tyumen/` → 301 Location: `/tyumen/tarif/`. - `curl -I http://localhost:8888/random-nonexistent/` → 404 (обычное поведение, нет редиректа). 4. `./release.sh 1.1.0` публикует Gitea release v1.1.0. 5. Gitea issue #1 закрывается с reference на коммит. ## Что НЕ входит в 1.1.0 - Lookup по post_type кроме `'page'`. ACF page_unic field обоих fields (`page_old`/`page_new`) ограничен `post_type='page'` (см. `OptionsPage::do_register`) — расширение бессмысленно. - Cache reverse-lookup. `page_unic` массив маленький (десятки rows max), foreach дешёвый. - Защита от output ссылок на private clones. Это handler-side fix; output-side — out of scope. - Фильтр `wpmc_redirect_post_types` для extensibility. YAGNI. ## Open questions Нет. Все решения зафиксированы.