spec(wpmc 1.1.0): Redirect::handle reverse-slug lookup on is_404 (washanyanya-style)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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: <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()`
|
||||
|
||||
```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
|
||||
|
||||
Нет. Все решения зафиксированы.
|
||||
Reference in New Issue
Block a user