Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 KiB
Redirect 1.1.0 — washanyanya-style reverse-slug lookup на is_404
- Beads:
cp-ifg(discovered-fromcp-83mS4) - Gitea issue: https://git.netranking.ru/bryzgalov/wp-multi-city/issues/1
- Дата: 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 → /<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 тестов)
test_bail_when_is_admin—is_admin = true, redirect не вызывается.test_bail_when_doing_ajax—DOING_AJAXtrue.test_bail_when_not_404—is_404 = false.test_bail_when_main_town_slug_null—get_field('main_town_slug')returns null.test_bail_when_request_uri_root—$_SERVER['REQUEST_URI'] = '/'.test_bail_when_no_post_with_slug—get_postsreturns[].
Lookup misses (3 теста)
test_bail_when_post_not_in_page_unic— slug найден, ноpost.IDне матчит ни одну row.test_bail_when_page_unic_empty—get_field('page_unic')returnsnull/[].test_bail_when_matched_town_is_main— page_unic matched, ноterm->slug === main_town_slug.
Successful redirect (3 теста)
test_redirect_for_private_clone— private post по slug, matched,wp_safe_redirect('/tyumen/tarif/', 301).test_redirect_for_published_clone_with_own_url— published post (статусpublishтоже в lookup), редирект работает идентично.test_redirect_strips_domain_from_get_permalink—get_permalinkreturnshttps://site.com/tarif/→ redirect target/tyumen/tarif/.
Wrapping (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
vendor/bin/phpunit— все green (~112 tests).- PHP lint:
find includes -name '*.php' -not -path '*/lib/*' -exec php -l {} \;— clean. - Smoke в
ural-dez.ruwp-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 (обычное поведение, нет редиректа).
- Создать
./release.sh 1.1.0публикует Gitea release v1.1.0.- 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
Нет. Все решения зафиксированы.