Files
wp-multi-city/docs/superpowers/plans/2026-05-19-redirect-reverse-slug-lookup.md
T
2026-05-19 02:11:33 +05:00

31 KiB
Raw Blame History

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() hooks wp@9998; Redirect::handle() triggers on $post->ID === page_unic[i].page_new
  • 4 tests in tests/RedirectTest.php cover 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, change wp priority 9998 → template_redirect priority 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 (because exit would 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_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

Leave only test_register_attaches_template_redirect_at_priority_1. Save.

  • Step 2: Runvendor/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_VERSIONrelease.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)
  1. cd ural-dez.ru && npx wp-env start (if not running).
  2. Create a public page tarif (note its ID, say N1).
  3. Create a private page tarif-tyumen (note its ID, say N2).
  4. WP admin → WP Multi City → Options → page_unic → add row: page_old=N1, page_new=N2, town=tyumen.
  5. curl -I http://localhost:8888/tarif-tyumen/ → expect HTTP/1.1 301 Moved Permanently and Location: /tyumen/tarif/.
  6. curl -I http://localhost:8888/random-nonexistent-slug/ → expect 404 Not Found with no redirect.
  • Release

Controller (not subagent) runs:

git push origin main
./release.sh 1.1.0

The release script will:

  1. Validate version, sync, token, readme.txt has = 1.1.0 =.
  2. Bump wp-multi-city.php Version: and WPMC_VERSION constant to 1.1.0.
  3. Bump readme.txt Stable tag to 1.1.0.
  4. Commit release: 1.1.0, push.
  5. Build zip, sanity-check, upload to new Gitea release v1.1.0.
  6. Regenerate wp-plugin-info.json with 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.