Files
wp-multi-city/docs/superpowers/specs/2026-05-19-redirect-reverse-slug-lookup-design.md
2026-05-19 02:04:06 +05:00

10 KiB
Raw Permalink Blame History

Redirect 1.1.0 — washanyanya-style reverse-slug lookup на is_404

Цель

Заменить текущую семантику 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 → /<town>/<orig-canonical-path>/; 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()

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_adminis_admin = true, redirect не вызывается.
  2. test_bail_when_doing_ajaxDOING_AJAX true.
  3. test_bail_when_not_404is_404 = false.
  4. test_bail_when_main_town_slug_nullget_field('main_town_slug') returns null.
  5. test_bail_when_request_uri_root$_SERVER['REQUEST_URI'] = '/'.
  6. test_bail_when_no_post_with_slugget_posts returns [].

Lookup misses (3 теста)

  1. test_bail_when_post_not_in_page_unic — slug найден, но post.ID не матчит ни одну row.
  2. test_bail_when_page_unic_emptyget_field('page_unic') returns null/[].
  3. test_bail_when_matched_town_is_main — page_unic matched, но term->slug === main_town_slug.

Successful redirect (3 теста)

  1. test_redirect_for_private_clone — private post по slug, matched, wp_safe_redirect('/tyumen/tarif/', 301).
  2. test_redirect_for_published_clone_with_own_url — published post (статус publish тоже в lookup), редирект работает идентично.
  3. test_redirect_strips_domain_from_get_permalinkget_permalink returns https://site.com/tarif/ → redirect target /tyumen/tarif/.

Wrapping (1 тест)

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

## [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

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