diff --git a/docs/superpowers/specs/2026-05-19-redirect-reverse-slug-lookup-design.md b/docs/superpowers/specs/2026-05-19-redirect-reverse-slug-lookup-design.md new file mode 100644 index 0000000..a9eedcb --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-redirect-reverse-slug-lookup-design.md @@ -0,0 +1,226 @@ +# 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 + +Нет. Все решения зафиксированы.