Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
31 KiB
Redirect 1.1.0 — washanyanya-style reverse-slug lookup — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Заменить Redirect::handle() на washanyanya-style: триггер на template_redirect priority 1, при is_404() ищет page по slug через get_posts(name=$slug, status=[private,publish]), матчит с page_unic, делает 301 на канонический /{city}/{orig-slug}/.
Architecture: Полный rewrite handle() + перезапись RedirectTest (13 новых тестов). Hook переезжает с wp@9998 на template_redirect@1. PluginBootS4Test обновляется. Никаких новых классов или файлов.
Tech Stack: PHP 8.0+, Brain Monkey 2.7 + Mockery 1.6 + Patchwork (тестовая инфра), PHPUnit 9.6.
Spec: docs/superpowers/specs/2026-05-19-redirect-reverse-slug-lookup-design.md
Beads: cp-ifg. Gitea issue: https://git.netranking.ru/bryzgalov/wp-multi-city/issues/1.
Current state (baseline):
- 109 tests / 208 assertions green
- Last commit on main:
20aff66 spec(wpmc 1.1.0): ... - Working tree clean
Redirect::register()hookswp@9998;Redirect::handle()triggers on$post->ID === page_unic[i].page_new- 4 tests in
tests/RedirectTest.phpcover old semantics
Task 1: Switch Redirect hook from wp@9998 to template_redirect@1
Files:
-
Modify:
includes/class-redirect.php(register() method, line 10) -
Modify:
tests/RedirectTest.php(test_register_attaches_wp_action_at_priority_9998 → rename + change assertion) -
Modify:
tests/PluginBootS4Test.php(line ~38, changewppriority 9998 →template_redirectpriority 1) -
Step 1: Update PluginBootS4Test assertion (red)
In tests/PluginBootS4Test.php, replace the existing Redirect-related assertion:
self::assertSame(
9998,
has_action('wp', ['WPMultiCity\\Redirect', 'handle']),
'boot() must register Redirect on wp@9998'
);
with:
self::assertSame(
1,
has_action('template_redirect', ['WPMultiCity\\Redirect', 'handle']),
'boot() must register Redirect on template_redirect@1'
);
- Step 2: Update RedirectTest hook test (red)
In tests/RedirectTest.php, replace the existing test:
public function test_register_attaches_wp_action_at_priority_9998(): void
{
Redirect::register();
self::assertSame(
9998,
has_action('wp', [Redirect::class, 'handle']),
'Redirect::register() must hook wp action at priority 9998'
);
}
with:
public function test_register_attaches_template_redirect_at_priority_1(): void
{
Redirect::register();
self::assertSame(
1,
has_action('template_redirect', [Redirect::class, 'handle']),
'Redirect::register() must hook template_redirect at priority 1'
);
}
- Step 3: Run both tests — confirm they fail
Run: vendor/bin/phpunit --filter 'test_register_attaches_template_redirect_at_priority_1|test_boot_registers'
Expected: FAIL (Redirect::register still hooks wp not template_redirect).
- Step 4: Update Redirect::register() (green)
In includes/class-redirect.php, replace:
public static function register(): void
{
\add_action('wp', [self::class, 'handle'], 9998);
}
with:
public static function register(): void
{
\add_action('template_redirect', [self::class, 'handle'], 1);
}
- Step 5: Run full test suite — some old RedirectTest tests will fail (expected)
Run: vendor/bin/phpunit
Expected: hook tests pass; the 3 other old RedirectTest tests (test_handle_skips_when_current_town_equals_main, test_handle_skips_when_post_not_set, test_handle_skips_when_url_already_has_town_prefix, test_handle_redirects_when_post_is_page_unic_clone_without_prefix) may still pass because they don't exercise the hook itself, only Redirect::handle() directly. If they all still pass, that's fine — we'll replace them in Task 2.
- Step 6: Commit
git add includes/class-redirect.php tests/RedirectTest.php tests/PluginBootS4Test.php
git commit -m "feat(wpmc 1.1.0): Redirect hook moves to template_redirect@1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 2: Rewrite Redirect::handle() to washanyanya-style + replace RedirectTest
Files:
- Rewrite:
includes/class-redirect.php(handle() method body — full replacement, +extract_last_segment helper) - Rewrite:
tests/RedirectTest.php(drop 4 old handle() tests, add 13 new tests covering new semantics)
This task is the core of 1.1.0. Follow strict TDD: write each new test FIRST, run to confirm RED for the right reason, then implement (or extend) the minimum logic to make it pass. Take the tests in the order listed.
Note: the test from Task 1 (test_register_attaches_template_redirect_at_priority_1) stays. Only the 4 test_handle_* tests from the old version are dropped. They are replaced by 13 new tests.
Common test setup (used by all 13 tests below). Pattern from existing tests/RedirectTest.php:
\Brain\Monkey\Functions\stubs(['did_action' => 1])so the ACF guard in Helpers methods passes.\Brain\Monkey\Functions\when('is_admin')->justReturn(false)by default; tests for admin override.- Use
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never()for bail-out cases. - Use
\Brain\Monkey\Functions\expect('wp_safe_redirect')->once()->with(...)->andThrow(new \RuntimeException('redirect-called'))then try/catch to verify success path (becauseexitwould terminate phpunit).
The new Redirect::handle() calls these WP functions: is_admin, is_404, get_posts, get_field, get_term_by, get_permalink, wp_safe_redirect. Plus helpers: Helpers::main_town_slug, Helpers::without_url_filters, Helpers::remove_domain_link. Stub the ones not exercised in each test using Functions\when(...)->justReturn(...) to keep tests focused.
Sub-step 2a: Drop the 4 old test_handle_* tests
- Step 1: Delete from
tests/RedirectTest.php— these 4 methods:test_handle_skips_when_current_town_equals_maintest_handle_skips_when_post_not_settest_handle_skips_when_url_already_has_town_prefixtest_handle_redirects_when_post_is_page_unic_clone_without_prefix
Leave only test_register_attaches_template_redirect_at_priority_1. Save.
- Step 2: Run —
vendor/bin/phpunit --filter RedirectTest— expect 1 test pass.
Sub-step 2b: Bail-out tests (write all 6, then implement the bail logic)
- Step 1: Write
test_bail_when_is_admin
Append to tests/RedirectTest.php:
public function test_bail_when_is_admin(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(true);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 2: Write
test_bail_when_doing_ajax
public function test_bail_when_doing_ajax(): void
{
if (!\defined('DOING_AJAX')) {
\define('DOING_AJAX', true);
}
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
Note: DOING_AJAX is a constant; if previous tests defined it, it stays. The first time this test runs, it defines it; subsequent runs in the same process are no-ops. Order-dependence is acceptable here because the bail itself doesn't depend on values changing — once defined, it's defined.
- Step 3: Write
test_bail_when_not_404
public function test_bail_when_not_404(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(false);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 4: Write
test_bail_when_main_town_slug_null
public function test_bail_when_main_town_slug_null(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return null;
return false;
});
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 5: Write
test_bail_when_request_uri_root
public function test_bail_when_request_uri_root(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
return false;
});
$_SERVER['REQUEST_URI'] = '/';
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
\Brain\Monkey\Functions\expect('get_posts')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 6: Write
test_bail_when_no_post_with_slug
public function test_bail_when_no_post_with_slug(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
return false;
});
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([]);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 7: Run all 6 bail-tests — confirm RED
Run: vendor/bin/phpunit --filter RedirectTest
Expected: register test passes; 6 bail tests fail (or some pass accidentally if old handle() happens to bail) — what matters is each test EITHER fails with a meaningful error OR passes for the right reason. Note the failures.
- Step 8: Rewrite
Redirect::handle()with bail logic only (no redirect yet)
In includes/class-redirect.php, replace the entire handle() body with the bail-out section:
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;
}
// TODO: matching + redirect (added in 2c)
}
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 !== ''
));
$last = end($parts);
return $last !== false && $last !== '' ? $last : null;
}
Note: keep extract_last_segment here — Task 2c will use it.
The placeholder // TODO: matching + redirect (added in 2c) is acceptable ONLY during this intermediate step — it will be replaced in Sub-step 2c. Confirm no production code goes through the TODO without removal.
- Step 9: Run all 6 bail-tests + register test — confirm GREEN
Run: vendor/bin/phpunit --filter RedirectTest
Expected: 7 tests pass (1 register + 6 bail). If test_bail_when_no_post_with_slug fails, ensure Helpers::main_town_slug() returns 'spb' correctly under stub (check ACF guard did_action).
Sub-step 2c: Lookup-miss tests (3 tests) + successful redirect tests (4 tests including wrapping)
- Step 1: Write
test_bail_when_post_not_in_page_unic
public function test_bail_when_post_not_in_page_unic(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return [
['town' => 11, 'page_old' => 42, 'page_new' => 84],
];
return false;
});
$_SERVER['REQUEST_URI'] = '/orphan-slug/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 999], // not 84
]);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 2: Write
test_bail_when_page_unic_empty
public function test_bail_when_page_unic_empty(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return null;
return false;
});
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 84],
]);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 3: Write
test_bail_when_matched_town_is_main
public function test_bail_when_matched_town_is_main(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return [
['town' => 10, 'page_old' => 42, 'page_new' => 84],
];
return false;
});
$_SERVER['REQUEST_URI'] = '/tarif-spb/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 84],
]);
$term = new \WP_Term();
$term->term_id = 10;
$term->slug = 'spb'; // == main_town_slug
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
- Step 4: Write
test_redirect_for_private_clone(will fail until 2c implementation)
public function test_redirect_for_private_clone(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return [
['town' => 11, 'page_old' => 42, 'page_new' => 84],
];
return false;
});
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 84, 'post_status' => 'private'],
]);
$term = new \WP_Term();
$term->term_id = 11;
$term->slug = 'tyumen';
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/tarif/');
\Brain\Monkey\Functions\expect('wp_safe_redirect')
->once()
->with('/tyumen/tarif/', 301)
->andThrow(new \RuntimeException('redirect-called'));
try {
Redirect::handle();
self::fail('Expected redirect to be issued');
} catch (\RuntimeException $e) {
self::assertSame('redirect-called', $e->getMessage());
}
}
- Step 5: Write
test_redirect_for_published_clone_with_own_url
public function test_redirect_for_published_clone_with_own_url(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return [
['town' => 11, 'page_old' => 42, 'page_new' => 84],
];
return false;
});
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 84, 'post_status' => 'publish'],
]);
$term = new \WP_Term();
$term->term_id = 11;
$term->slug = 'tyumen';
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/tarif/');
\Brain\Monkey\Functions\expect('wp_safe_redirect')
->once()
->with('/tyumen/tarif/', 301)
->andThrow(new \RuntimeException('redirect-called'));
try {
Redirect::handle();
self::fail('Expected redirect to be issued');
} catch (\RuntimeException $e) {
self::assertSame('redirect-called', $e->getMessage());
}
}
- Step 6: Write
test_redirect_strips_domain_from_get_permalink
public function test_redirect_strips_domain_from_get_permalink(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return [
['town' => 11, 'page_old' => 42, 'page_new' => 84],
];
return false;
});
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 84],
]);
$term = new \WP_Term();
$term->term_id = 11;
$term->slug = 'tyumen';
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
// get_permalink returns full URL — handler must strip host
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/tarif/');
\Brain\Monkey\Functions\expect('wp_safe_redirect')
->once()
->with('/tyumen/tarif/', 301)
->andThrow(new \RuntimeException('redirect-called'));
try {
Redirect::handle();
self::fail('Expected redirect to be issued');
} catch (\RuntimeException $e) {
self::assertSame('redirect-called', $e->getMessage());
}
}
- Step 7: Write
test_get_permalink_called_inside_without_url_filters
public function test_get_permalink_called_inside_without_url_filters(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return [
['town' => 11, 'page_old' => 42, 'page_new' => 84],
];
return false;
});
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
\Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 84],
]);
$term = new \WP_Term();
$term->term_id = 11;
$term->slug = 'tyumen';
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
// Capture bypass depth at the moment of get_permalink call
$depth_during_call = -1;
\Brain\Monkey\Functions\when('get_permalink')->alias(
function () use (&$depth_during_call) {
$depth_during_call = \WPMultiCity\Helpers::is_bypassed() ? 1 : 0;
return 'https://site.com/tarif/';
}
);
\Brain\Monkey\Functions\expect('wp_safe_redirect')
->once()
->andThrow(new \RuntimeException('redirect-called'));
try {
Redirect::handle();
} catch (\RuntimeException $e) {
// expected
}
self::assertSame(
1,
$depth_during_call,
'get_permalink must be wrapped in Helpers::without_url_filters'
);
self::assertFalse(
\WPMultiCity\Helpers::is_bypassed(),
'bypass depth must be restored to 0 after handle()'
);
}
- Step 8: Run all RedirectTest — confirm RED for redirect cases
Run: vendor/bin/phpunit --filter RedirectTest
Expected: 10 tests pass (1 register + 6 bail + 3 lookup-miss BAIL — these may incidentally pass because handle() returns silently due to TODO placeholder); 4 redirect tests FAIL (because no wp_safe_redirect is being called).
- Step 9: Implement matching + redirect — finish
Redirect::handle()
In includes/class-redirect.php, replace the // TODO: matching + redirect (added in 2c) placeholder with:
$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;
}
After replacement, handle() should contain NO TODO comments.
- Step 10: Run full RedirectTest — confirm all 14 GREEN (1 register + 13 new)
Run: vendor/bin/phpunit --filter RedirectTest
Expected: 14 tests pass.
- Step 11: Run full suite to verify nothing else broke
Run: vendor/bin/phpunit
Expected: ~118 tests (109 baseline - 4 old + 13 new = 118), all green. If some other test fails (e.g., from RouterTest because of stub bleed), inspect and fix root cause. Do NOT change RouterTest assertions; instead check whether the new handle() inadvertently runs during another test (it shouldn't — handle() is only called when template_redirect fires).
- Step 12: Commit
git add includes/class-redirect.php tests/RedirectTest.php
git commit -m "feat(wpmc 1.1.0): Redirect::handle reverse-slug lookup on is_404
Replace old triggering-on-page_new semantics with washanyanya-style:
- template_redirect@1 + is_404() trigger
- get_posts(name=\$slug, post_type=page, status=[private,publish])
- match candidate.ID against page_unic[i].page_new
- 301 to /<town>/<orig-canonical-path>/
Closes Gitea issue #1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 3: Bump changelog (readme.txt + CHANGELOG.md)
Files:
- Modify:
readme.txt(add= 1.1.0 =block in== Changelog ==BEFORE= 1.0.1 =) - Modify:
CHANGELOG.md(add## [1.1.0]section at top, after the title)
NOTE: Do NOT bump wp-multi-city.php Version or WPMC_VERSION — release.sh does that.
- Step 1: Read current readme.txt to find insertion point
Run: grep -n "^= " readme.txt
Confirm the order: = 1.0.1 = then = 1.0.0 =. Insert = 1.1.0 = BEFORE = 1.0.1 =.
- Step 2: Edit readme.txt
In readme.txt, locate the == Changelog == section. Add this block immediately before the existing = 1.0.1 = line:
= 1.1.0 =
* Change: `Redirect::handle()` переписан на washanyanya-style. Триггерится на `is_404()` (хук `template_redirect` priority 1), ищет private/publish page по URL slug через `get_posts`, матчит с `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).
(Note the empty line after the block to separate it from = 1.0.1 =.)
- Step 3: Verify readme.txt
Run: grep -E "^= [0-9]+\.[0-9]+\.[0-9]+ =$" readme.txt
Expected output:
= 1.1.0 =
= 1.0.1 =
= 1.0.0 =
- Step 4: Read CHANGELOG.md to find insertion point
Run: head -5 CHANGELOG.md
Confirm structure: title, blank line, format-line, blank, then ## [1.0.1] — ....
- Step 5: Edit CHANGELOG.md
In CHANGELOG.md, add a new section immediately after the format-line (or wherever the most recent version section currently lives) — BEFORE the existing ## [1.0.1] block:
## [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`).
- Hook переехал с `wp` priority 9998 на `template_redirect` priority 1.
Closes [Gitea issue #1](https://git.netranking.ru/bryzgalov/wp-multi-city/issues/1).
- Step 6: Verify CHANGELOG.md
Run: grep -E "^## \[" CHANGELOG.md
Expected output (in order):
## [1.1.0] — 2026-05-19
## [1.0.1] — 2026-05-17
## [1.0.0] — 2026-05-17
- Step 7: Run full suite (no code changes here but verify nothing accidentally broke)
Run: vendor/bin/phpunit
Expected: same green count as after Task 2.
- Step 8: Commit
git add readme.txt CHANGELOG.md
git commit -m "docs(wpmc 1.1.0): changelog entries in readme.txt + CHANGELOG.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Final verification
- Run full PHPUnit suite
Run: vendor/bin/phpunit
Expected: OK, ~118 tests, ~217+ assertions. All green.
- PHP lint clean
Run: find includes -name '*.php' -not -path '*/lib/*' -exec php -l {} \;
Expected: every line "No syntax errors detected in ...".
- Working tree clean
Run: git status
Expected: nothing to commit, working tree clean.
- Smoke test in ural-dez.ru wp-env (manual, optional but recommended)
cd ural-dez.ru && npx wp-env start(if not running).- Create a public page
tarif(note its ID, say N1). - Create a private page
tarif-tyumen(note its ID, say N2). - WP admin → WP Multi City → Options → page_unic → add row:
page_old=N1,page_new=N2,town=tyumen. curl -I http://localhost:8888/tarif-tyumen/→ expectHTTP/1.1 301 Moved PermanentlyandLocation: /tyumen/tarif/.curl -I http://localhost:8888/random-nonexistent-slug/→ expect404 Not Foundwith no redirect.
- Release
Controller (not subagent) runs:
git push origin main
./release.sh 1.1.0
The release script will:
- Validate version, sync, token, readme.txt has
= 1.1.0 =. - Bump
wp-multi-city.phpVersion: andWPMC_VERSIONconstant to1.1.0. - Bump
readme.txtStable tag to1.1.0. - Commit
release: 1.1.0, push. - Build zip, sanity-check, upload to new Gitea release v1.1.0.
- Regenerate
wp-plugin-info.jsonwith new download_url + commit + push.
- Close Gitea issue #1 + bd close cp-ifg
Controller (not subagent) closes Gitea issue #1 via API or web UI with reference to release commit, then:
bd close cp-ifg --reason "Released in 1.1.0. Redirect::handle переписан на washanyanya-style: template_redirect@1 + is_404 + get_posts reverse-slug lookup + page_unic match → 301 на /<city>/<orig>/. 13 tests cover new semantics. Closes Gitea issue #1."
Spec coverage check
| Spec section | Tasks |
|---|---|
| Artifacts (class-redirect.php) | Task 1, Task 2 |
| Artifacts (RedirectTest.php) | Task 2 (rewrite) |
| Artifacts (PluginBootS4Test.php) | Task 1 |
| Artifacts (readme.txt) | Task 3 |
| Artifacts (CHANGELOG.md) | Task 3 |
Алгоритм handle() (bail + lookup + match) |
Task 2 sub-steps 2b/2c |
extract_last_segment helper |
Task 2 sub-step 2b step 8 |
| 6 bail-out tests | Task 2 sub-step 2b |
| 3 lookup-miss tests | Task 2 sub-step 2c (steps 1-3) |
| 3 successful redirect tests | Task 2 sub-step 2c (steps 4-6) |
1 wrapping test (without_url_filters) |
Task 2 sub-step 2c (step 7) |
Hook change template_redirect@1 |
Task 1 |
| Acceptance verification | Final verification block |
| Release 1.1.0 | Final verification block (controller-driven) |
| Close Gitea issue #1 + bd close cp-ifg | Final verification block (controller-driven) |
All spec requirements covered.