14 Commits

Author SHA1 Message Date
Vladimir Bryzgalov 771c949ba8 release: 1.1.0
tests / phpunit (push) Has been cancelled
2026-05-19 02:31:57 +05:00
Vladimir Bryzgalov 07ed1703ba fix(wpmc 1.1.0): review polish — continue on bad permalink, ACF guard, drop suppress_filters, process-isolated AJAX test
tests / phpunit (push) Has been cancelled
- Redirect::handle: continue instead of return when get_permalink fails on a row (allow trying next row)
- Helpers::acf_ready() now public so Redirect can guard get_field('page_unic', ...) consistently
- Drop suppress_filters=false from get_posts (was risky on sites with query-altering plugins)
- @runInSeparateProcess for DOING_AJAX test (replaces Z-prefix ordering hack)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:30:26 +05:00
Vladimir Bryzgalov 9b1689dc2f docs(wpmc 1.1.0): changelog entries in readme.txt + CHANGELOG.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:23:52 +05:00
Vladimir Bryzgalov 43e3efc5a2 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>
2026-05-19 02:22:24 +05:00
Vladimir Bryzgalov 9586810507 feat(wpmc 1.1.0): Redirect hook moves to template_redirect@1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:15:46 +05:00
Vladimir Bryzgalov 0ec9733368 docs(wpmc 1.1.0): implementation plan for Redirect reverse-slug lookup
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:11:33 +05:00
Vladimir Bryzgalov 20aff663d4 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>
2026-05-19 02:04:06 +05:00
Vladimir Bryzgalov d0a7b8eb9d wp-plugin-info.json: update for 1.0.1
tests / phpunit (push) Has been cancelled
2026-05-17 17:02:11 +05:00
Vladimir Bryzgalov d36408a4a6 release: 1.0.1
tests / phpunit (push) Has been cancelled
2026-05-17 17:02:11 +05:00
Vladimir Bryzgalov c002b08275 docs(wpmc 1.0.1): changelog entries in readme.txt + CHANGELOG.md
tests / phpunit (push) Has been cancelled
Add 1.0.1 release notes documenting the three hotfixes: procedural wrapper,
WP 6.7 textdomain notice fix, and ACF early-call guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:01:23 +05:00
Vladimir Bryzgalov 82d2345d12 fix(wpmc 1.0.1): guard ACF helpers with did_action('acf/init')
Add private Helpers::acf_ready() that checks did_action('acf/init') > 0,
then guard the five ACF-dependent methods (alt_get_field, index_town_alt_page_id,
main_town_slug, page_unic_swap, is_routable_path) so they return safe defaults
(null/true) before ACF initialises. Fixes the acf_get_value "called too early"
notice triggered when home_url filter fires before acf/init.

Update existing tests in HelpersTest, LinkFilterTest, RedirectTest, RouterTest
to stub did_action => 1 where ACF-ready behaviour is expected. Add four new
guard tests covering before/after acf/init for main_town_slug and is_routable_path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:01:19 +05:00
Vladimir Bryzgalov 949fb19e95 fix(wpmc 1.0.1): remove __() from labels (no translations, fixes WP 6.7 notice)
Replace all __('...', 'wp-multi-city') calls in CPT, taxonomy, ACF field group,
and options page label arrays with literal Russian strings. The plugin ships no
.mo translation files, so wrapping labels in __() was YAGNI and triggered the
WP 6.7+ _load_textdomain_just_in_time notice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:01:11 +05:00
Vladimir Bryzgalov 19409ac062 feat(wpmc 1.0.1): wpmc_main_town_slug procedural wrapper
Add wpmc_main_town_slug() wrapper function to wpmc-functions.php, delegating
to Helpers::main_town_slug(). The function was documented in the Hook API
README but was missing from the codebase.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:01:05 +05:00
Vladimir Bryzgalov 4c6173619b wp-plugin-info.json: update for 1.0.0
tests / phpunit (push) Has been cancelled
2026-05-17 12:49:33 +05:00
20 changed files with 1589 additions and 102 deletions
+21
View File
@@ -2,6 +2,27 @@
All notable changes to wp-multi-city. Format follows [Keep a Changelog](https://keepachangelog.com/). All notable changes to wp-multi-city. Format follows [Keep a Changelog](https://keepachangelog.com/).
## [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).
## [1.0.1] — 2026-05-17
### Added
- Процедурный wrapper `wpmc_main_town_slug()` (документирован в README, отсутствовал в коде).
### Fixed
- WP 6.7+ notice `_load_textdomain_just_in_time`: убраны `__()` из labels CPT/таксономии/ACF field group.
- ACF `acf_get_value` "called too early": `did_action('acf/init')` guard в 5 Helpers методах.
## [1.0.0] — 2026-05-17 ## [1.0.0] — 2026-05-17
### Added ### Added
@@ -0,0 +1,837 @@
# 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:
```php
self::assertSame(
9998,
has_action('wp', ['WPMultiCity\\Redirect', 'handle']),
'boot() must register Redirect on wp@9998'
);
```
with:
```php
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:
```php
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:
```php
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:
```php
public static function register(): void
{
\add_action('wp', [self::class, 'handle'], 9998);
}
```
with:
```php
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**
```bash
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: 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`:
```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`**
```php
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 `define`d 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`**
```php
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`**
```php
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`**
```php
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`**
```php
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:
```php
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`**
```php
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`**
```php
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`**
```php
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)**
```php
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`**
```php
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`**
```php
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`**
```php
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:
```php
$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**
```bash
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:
```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`).
- 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**
```bash
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:
```bash
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:
```bash
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.
@@ -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
Нет. Все решения зафиксированы.
+4 -4
View File
@@ -25,15 +25,15 @@ final class FieldGroup
'fields' => [ 'fields' => [
[ [
'key' => self::REPEATER_KEY, 'key' => self::REPEATER_KEY,
'label' => __('Подстановки по городам', 'wp-multi-city'), 'label' => 'Подстановки по городам',
'name' => 'list_shortcode', 'name' => 'list_shortcode',
'type' => 'repeater', 'type' => 'repeater',
'layout' => 'table', 'layout' => 'table',
'button_label' => __('Добавить город', 'wp-multi-city'), 'button_label' => 'Добавить город',
'sub_fields' => [ 'sub_fields' => [
[ [
'key' => 'field_wpmc_list_shortcode_town', 'key' => 'field_wpmc_list_shortcode_town',
'label' => __('Город', 'wp-multi-city'), 'label' => 'Город',
'name' => 'town', 'name' => 'town',
'type' => 'taxonomy', 'type' => 'taxonomy',
'taxonomy' => 'town', 'taxonomy' => 'town',
@@ -45,7 +45,7 @@ final class FieldGroup
], ],
[ [
'key' => 'field_wpmc_list_shortcode_text', 'key' => 'field_wpmc_list_shortcode_text',
'label' => __('Значение', 'wp-multi-city'), 'label' => 'Значение',
'name' => 'text', 'name' => 'text',
'type' => 'textarea', 'type' => 'textarea',
'rows' => 4, 'rows' => 4,
+20
View File
@@ -11,6 +11,11 @@ final class Helpers
private static array $page_unic_cache = []; private static array $page_unic_cache = [];
private static int $bypass_depth = 0; private static int $bypass_depth = 0;
public static function acf_ready(): bool
{
return \did_action('acf/init') > 0;
}
public static function reset_cache(): void public static function reset_cache(): void
{ {
self::$current_town_memo = null; self::$current_town_memo = null;
@@ -80,6 +85,9 @@ final class Helpers
public static function alt_get_field(string $selector, $post_id = false, bool $format_value = true) public static function alt_get_field(string $selector, $post_id = false, bool $format_value = true)
{ {
if (!self::acf_ready()) {
return null;
}
$post_id = \apply_filters('wpmc_alt_get_field_post_id', $post_id, $selector); $post_id = \apply_filters('wpmc_alt_get_field_post_id', $post_id, $selector);
if (!\function_exists('get_field')) { if (!\function_exists('get_field')) {
return null; return null;
@@ -106,6 +114,9 @@ final class Helpers
public static function index_town_alt_page_id(): ?int public static function index_town_alt_page_id(): ?int
{ {
if (!self::acf_ready()) {
return null;
}
if (!\function_exists('get_field')) { if (!\function_exists('get_field')) {
return null; return null;
} }
@@ -116,6 +127,9 @@ final class Helpers
public static function main_town_slug(): ?string public static function main_town_slug(): ?string
{ {
if (!self::acf_ready()) {
return null;
}
if (!\function_exists('get_field')) { if (!\function_exists('get_field')) {
return null; return null;
} }
@@ -132,6 +146,9 @@ final class Helpers
if (array_key_exists($key, self::$page_unic_cache)) { if (array_key_exists($key, self::$page_unic_cache)) {
return self::$page_unic_cache[$key]; return self::$page_unic_cache[$key];
} }
if (!self::acf_ready()) {
return null;
}
$term = self::get_term_by_slug($town_slug); $term = self::get_term_by_slug($town_slug);
if ($term === null) { if ($term === null) {
return self::$page_unic_cache[$key] = null; return self::$page_unic_cache[$key] = null;
@@ -161,6 +178,9 @@ final class Helpers
if ($path === null) { if ($path === null) {
$path = $_SERVER['REQUEST_URI'] ?? '/'; $path = $_SERVER['REQUEST_URI'] ?? '/';
} }
if (!self::acf_ready()) {
return true;
}
if (!\function_exists('get_field')) { if (!\function_exists('get_field')) {
return true; return true;
} }
+11 -11
View File
@@ -35,7 +35,7 @@ final class OptionsPage
'fields' => [ 'fields' => [
[ [
'key' => 'field_wpmc_main_town_slug', 'key' => 'field_wpmc_main_town_slug',
'label' => __('Главный город (без префикса URL)', 'wp-multi-city'), 'label' => 'Главный город (без префикса URL)',
'name' => 'main_town_slug', 'name' => 'main_town_slug',
'type' => 'taxonomy', 'type' => 'taxonomy',
'taxonomy' => 'town', 'taxonomy' => 'town',
@@ -47,7 +47,7 @@ final class OptionsPage
], ],
[ [
'key' => 'field_wpmc_index_town_alt', 'key' => 'field_wpmc_index_town_alt',
'label' => __('Главная для других городов', 'wp-multi-city'), 'label' => 'Главная для других городов',
'name' => 'index_town_alt', 'name' => 'index_town_alt',
'type' => 'post_object', 'type' => 'post_object',
'post_type' => ['page'], 'post_type' => ['page'],
@@ -57,15 +57,15 @@ final class OptionsPage
], ],
[ [
'key' => 'field_wpmc_page_unic', 'key' => 'field_wpmc_page_unic',
'label' => __('Клоны страниц', 'wp-multi-city'), 'label' => 'Клоны страниц',
'name' => 'page_unic', 'name' => 'page_unic',
'type' => 'repeater', 'type' => 'repeater',
'layout' => 'table', 'layout' => 'table',
'button_label' => __('Добавить клон', 'wp-multi-city'), 'button_label' => 'Добавить клон',
'sub_fields' => [ 'sub_fields' => [
[ [
'key' => 'field_wpmc_page_unic_page_old', 'key' => 'field_wpmc_page_unic_page_old',
'label' => __('Оригинал', 'wp-multi-city'), 'label' => 'Оригинал',
'name' => 'page_old', 'name' => 'page_old',
'type' => 'post_object', 'type' => 'post_object',
'post_type' => ['page'], 'post_type' => ['page'],
@@ -75,7 +75,7 @@ final class OptionsPage
], ],
[ [
'key' => 'field_wpmc_page_unic_page_new', 'key' => 'field_wpmc_page_unic_page_new',
'label' => __('Клон', 'wp-multi-city'), 'label' => 'Клон',
'name' => 'page_new', 'name' => 'page_new',
'type' => 'post_object', 'type' => 'post_object',
'post_type' => ['page'], 'post_type' => ['page'],
@@ -85,7 +85,7 @@ final class OptionsPage
], ],
[ [
'key' => 'field_wpmc_page_unic_town', 'key' => 'field_wpmc_page_unic_town',
'label' => __('Город', 'wp-multi-city'), 'label' => 'Город',
'name' => 'town', 'name' => 'town',
'type' => 'taxonomy', 'type' => 'taxonomy',
'taxonomy' => 'town', 'taxonomy' => 'town',
@@ -99,18 +99,18 @@ final class OptionsPage
], ],
[ [
'key' => 'field_wpmc_page_no_rep', 'key' => 'field_wpmc_page_no_rep',
'label' => __('Исключения из роутинга', 'wp-multi-city'), 'label' => 'Исключения из роутинга',
'name' => 'page_no_rep', 'name' => 'page_no_rep',
'type' => 'repeater', 'type' => 'repeater',
'layout' => 'table', 'layout' => 'table',
'button_label' => __('Добавить исключение', 'wp-multi-city'), 'button_label' => 'Добавить исключение',
'sub_fields' => [ 'sub_fields' => [
[ [
'key' => 'field_wpmc_page_no_rep_link', 'key' => 'field_wpmc_page_no_rep_link',
'label' => __('Путь', 'wp-multi-city'), 'label' => 'Путь',
'name' => 'link', 'name' => 'link',
'type' => 'text', 'type' => 'text',
'instructions' => __('Префикс URL, например /privacy/', 'wp-multi-city'), 'instructions' => 'Префикс URL, например /privacy/',
], ],
], ],
], ],
+10 -10
View File
@@ -16,16 +16,16 @@ final class PostType
{ {
register_post_type(self::SLUG, [ register_post_type(self::SLUG, [
'labels' => [ 'labels' => [
'name' => __('Шорткоды', 'wp-multi-city'), 'name' => 'Шорткоды',
'singular_name' => __('Шорткод', 'wp-multi-city'), 'singular_name' => 'Шорткод',
'menu_name' => __('Шорткоды', 'wp-multi-city'), 'menu_name' => 'Шорткоды',
'add_new' => __('Добавить', 'wp-multi-city'), 'add_new' => 'Добавить',
'add_new_item' => __('Добавить шорткод', 'wp-multi-city'), 'add_new_item' => 'Добавить шорткод',
'edit_item' => __('Редактировать', 'wp-multi-city'), 'edit_item' => 'Редактировать',
'new_item' => __('Новый', 'wp-multi-city'), 'new_item' => 'Новый',
'view_item' => __('Смотреть', 'wp-multi-city'), 'view_item' => 'Смотреть',
'search_items' => __('Поиск', 'wp-multi-city'), 'search_items' => 'Поиск',
'not_found' => __('Не найдено', 'wp-multi-city'), 'not_found' => 'Не найдено',
], ],
'public' => false, 'public' => false,
'exclude_from_search' => true, 'exclude_from_search' => true,
+48 -24
View File
@@ -7,65 +7,89 @@ final class Redirect
{ {
public static function register(): void public static function register(): void
{ {
\add_action('wp', [self::class, 'handle'], 9998); \add_action('template_redirect', [self::class, 'handle'], 1);
} }
public static function handle(): void public static function handle(): void
{ {
if (\is_admin()) { if (\is_admin() || (\defined('DOING_AJAX') && \DOING_AJAX)) {
return; return;
} }
if (\defined('DOING_AJAX') && \DOING_AJAX) { if (!\is_404()) {
return; return;
} }
$main = Helpers::main_town_slug(); $main = Helpers::main_town_slug();
if ($main === null) { if ($main === null) {
return; return;
} }
global $post;
if (!isset($post->ID)) { $slug = self::extract_last_segment($_SERVER['REQUEST_URI'] ?? '/');
if ($slug === null) {
return; return;
} }
$current_page_id = (int) $post->ID;
if (!\function_exists('get_field')) { $candidates = \get_posts([
'name' => $slug,
'post_type' => 'page',
'post_status' => ['private', 'publish'],
'posts_per_page' => 1,
'no_found_rows' => true,
]);
if (empty($candidates)) {
return; return;
} }
$page_unic = \get_field('page_unic', 'option');
if (!is_array($page_unic)) { $candidate_id = (int) $candidates[0]->ID;
if (!Helpers::acf_ready() || !\function_exists('get_field')) {
return; return;
} }
$request_uri = $_SERVER['REQUEST_URI'] ?? '/'; $rows = \get_field('page_unic', 'option');
foreach ($page_unic as $row) { if (!is_array($rows)) {
return;
}
foreach ($rows as $row) {
if (!is_array($row)) { if (!is_array($row)) {
continue; continue;
} }
if ((int) ($row['page_new'] ?? 0) !== $current_page_id) { if ((int) ($row['page_new'] ?? 0) !== $candidate_id) {
continue; continue;
} }
$town_term_id = (int) ($row['town'] ?? 0); $town_term_id = (int) ($row['town'] ?? 0);
if (!$town_term_id) { $orig_id = (int) ($row['page_old'] ?? 0);
if (!$town_term_id || !$orig_id) {
continue; continue;
} }
$term = \get_term_by('id', $town_term_id, 'town'); $term = \get_term_by('id', $town_term_id, 'town');
if (!($term instanceof \WP_Term) || $term->slug === '' || $term->slug === $main) { if (!($term instanceof \WP_Term) || $term->slug === '' || $term->slug === $main) {
continue; continue;
} }
$town_slug = $term->slug; $orig_permalink = Helpers::without_url_filters(
if (preg_match('#^/' . preg_quote($town_slug, '#') . '(/|$)#', $request_uri)) { static fn () => \get_permalink($orig_id)
return; );
}
$orig_id = (int) ($row['page_old'] ?? 0);
if (!$orig_id) {
return;
}
$orig_permalink = Helpers::without_url_filters(static fn () => \get_permalink($orig_id));
if (!is_string($orig_permalink) || $orig_permalink === '') { if (!is_string($orig_permalink) || $orig_permalink === '') {
return; continue;
} }
$path_only = Helpers::remove_domain_link($orig_permalink); $path_only = Helpers::remove_domain_link($orig_permalink);
$redirect_to = '/' . $town_slug . $path_only; $redirect_to = '/' . $term->slug . $path_only;
\wp_safe_redirect($redirect_to, 301); \wp_safe_redirect($redirect_to, 301);
exit; 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 !== ''
));
$last = end($parts);
return $last !== false && $last !== '' ? $last : null;
}
} }
+4 -4
View File
@@ -16,10 +16,10 @@ final class Taxonomy
{ {
register_taxonomy(self::SLUG, [PostType::SLUG], [ register_taxonomy(self::SLUG, [PostType::SLUG], [
'labels' => [ 'labels' => [
'name' => __('Города', 'wp-multi-city'), 'name' => 'Города',
'singular_name' => __('Город', 'wp-multi-city'), 'singular_name' => 'Город',
'menu_name' => __('Города', 'wp-multi-city'), 'menu_name' => 'Города',
'add_new_item' => __('Добавить', 'wp-multi-city'), 'add_new_item' => 'Добавить',
], ],
'public' => false, 'public' => false,
'publicly_queryable' => false, 'publicly_queryable' => false,
+7
View File
@@ -30,3 +30,10 @@ if (!function_exists('wpmc_remove_domain_link')) {
return Helpers::remove_domain_link($url); return Helpers::remove_domain_link($url);
} }
} }
if (!function_exists('wpmc_main_town_slug')) {
function wpmc_main_town_slug(): ?string
{
return Helpers::main_town_slug();
}
}
+10 -1
View File
@@ -4,7 +4,7 @@ Tags: multisite, multi-city, taxonomy, acf, shortcode
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.9 Tested up to: 6.9
Requires PHP: 8.0 Requires PHP: 8.0
Stable tag: 1.0.0 Stable tag: 1.1.0
License: GPLv2 or later License: GPLv2 or later
Мультигородская подсистема: одна WP-инсталляция обслуживает несколько городов по схеме /{city}/{path}/ с подстановками контента и URL. Мультигородская подсистема: одна WP-инсталляция обслуживает несколько городов по схеме /{city}/{path}/ с подстановками контента и URL.
@@ -23,6 +23,15 @@ License: GPLv2 or later
== Changelog == == Changelog ==
= 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).
= 1.0.1 =
* Add: процедурный wrapper `wpmc_main_town_slug()` (был задокументирован в README, но отсутствовал в коде).
* Fix: WP 6.7+ notice `_load_textdomain_just_in_time` — убраны `__()` обёртки из labels CPT/таксономии/ACF field group (плагин не поставляется с переводами).
* Fix: ACF `acf_get_value` "called too early" notice — добавлен `did_action('acf/init')` guard в `Helpers::main_town_slug()`, `index_town_alt_page_id`, `page_unic_swap`, `is_routable_path`, `alt_get_field`. До инициализации ACF возвращают безопасные defaults (null/true).
= 1.0.0 = = 1.0.0 =
* Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4). * Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4).
* taxonomy town + CPT shortcode-town + ACF list_shortcode repeater. * taxonomy town + CPT shortcode-town + ACF list_shortcode repeater.
+69
View File
@@ -133,6 +133,7 @@ final class HelpersTest extends TestCase
public function test_alt_get_field_applies_do_shortcode_to_string_values(): void public function test_alt_get_field_applies_do_shortcode_to_string_values(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
// expect() must come before when('function_exists') so Brain Monkey's // expect() must come before when('function_exists') so Brain Monkey's
// FunctionStub::__construct can eval-define get_field() before the shim // FunctionStub::__construct can eval-define get_field() before the shim
// fakes function_exists('get_field') === true. // fakes function_exists('get_field') === true.
@@ -153,6 +154,7 @@ final class HelpersTest extends TestCase
public function test_alt_get_field_returns_arrays_as_is(): void public function test_alt_get_field_returns_arrays_as_is(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
// expect() before when('function_exists') to avoid FunctionStub seeing // expect() before when('function_exists') to avoid FunctionStub seeing
// the shim and skipping the eval-declaration of get_field. // the shim and skipping the eval-declaration of get_field.
// do_shortcode is intentionally NOT registered: if the implementation // do_shortcode is intentionally NOT registered: if the implementation
@@ -167,6 +169,7 @@ final class HelpersTest extends TestCase
public function test_alt_get_field_post_id_filter_overrides_caller_id(): void public function test_alt_get_field_post_id_filter_overrides_caller_id(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
->with('title', 99, true) ->with('title', 99, true)
@@ -207,13 +210,27 @@ final class HelpersTest extends TestCase
self::assertTrue(function_exists('wpmc_get_term_by_slug')); self::assertTrue(function_exists('wpmc_get_term_by_slug'));
self::assertTrue(function_exists('wpmc_alt_get_field')); self::assertTrue(function_exists('wpmc_alt_get_field'));
self::assertTrue(function_exists('wpmc_remove_domain_link')); self::assertTrue(function_exists('wpmc_remove_domain_link'));
self::assertTrue(function_exists('wpmc_main_town_slug'));
// remove_domain_link is pure (no WP function dependencies) — exercise the proxy. // remove_domain_link is pure (no WP function dependencies) — exercise the proxy.
self::assertSame('/foo/', wpmc_remove_domain_link('https://site.com/foo/')); self::assertSame('/foo/', wpmc_remove_domain_link('https://site.com/foo/'));
} }
public function test_wpmc_main_town_slug_delegates_to_helpers(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field')
->once()
->with('main_town_slug', 'option')
->andReturn('spb');
require_once dirname(__DIR__) . '/includes/wpmc-functions.php';
self::assertSame('spb', wpmc_main_town_slug());
}
public function test_main_town_slug_reads_option_via_acf(): void public function test_main_town_slug_reads_option_via_acf(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
->with('main_town_slug', 'option') ->with('main_town_slug', 'option')
@@ -224,6 +241,7 @@ final class HelpersTest extends TestCase
public function test_main_town_slug_returns_null_when_option_empty(): void public function test_main_town_slug_returns_null_when_option_empty(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
->with('main_town_slug', 'option') ->with('main_town_slug', 'option')
@@ -234,6 +252,7 @@ final class HelpersTest extends TestCase
public function test_is_routable_path_returns_true_when_no_exclusions(): void public function test_is_routable_path_returns_true_when_no_exclusions(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
->with('page_no_rep', 'option') ->with('page_no_rep', 'option')
@@ -244,6 +263,7 @@ final class HelpersTest extends TestCase
public function test_is_routable_path_returns_false_when_path_matches_exclusion(): void public function test_is_routable_path_returns_false_when_path_matches_exclusion(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
->with('page_no_rep', 'option') ->with('page_no_rep', 'option')
@@ -257,6 +277,7 @@ final class HelpersTest extends TestCase
public function test_is_routable_path_uses_request_uri_when_path_null(): void public function test_is_routable_path_uses_request_uri_when_path_null(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
$_SERVER['REQUEST_URI'] = '/terms/'; $_SERVER['REQUEST_URI'] = '/terms/';
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
@@ -268,6 +289,7 @@ final class HelpersTest extends TestCase
public function test_page_unic_swap_returns_clone_id_when_row_matches(): void public function test_page_unic_swap_returns_clone_id_when_row_matches(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
$term = new \WP_Term(); $term = new \WP_Term();
$term->term_id = 11; $term->term_id = 11;
$term->slug = 'tyumen'; $term->slug = 'tyumen';
@@ -285,6 +307,7 @@ final class HelpersTest extends TestCase
public function test_page_unic_swap_returns_null_when_no_row_matches(): void public function test_page_unic_swap_returns_null_when_no_row_matches(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
$term = new \WP_Term(); $term = new \WP_Term();
$term->term_id = 11; $term->term_id = 11;
$term->slug = 'tyumen'; $term->slug = 'tyumen';
@@ -298,6 +321,7 @@ final class HelpersTest extends TestCase
public function test_page_unic_swap_caches_within_request(): void public function test_page_unic_swap_caches_within_request(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
$term = new \WP_Term(); $term = new \WP_Term();
$term->term_id = 11; $term->term_id = 11;
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term); \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
@@ -314,6 +338,7 @@ final class HelpersTest extends TestCase
public function test_index_town_alt_page_id_returns_int_when_option_set(): void public function test_index_town_alt_page_id_returns_int_when_option_set(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
->with('index_town_alt', 'option') ->with('index_town_alt', 'option')
@@ -324,6 +349,7 @@ final class HelpersTest extends TestCase
public function test_index_town_alt_page_id_returns_null_when_zero(): void public function test_index_town_alt_page_id_returns_null_when_zero(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field') \Brain\Monkey\Functions\expect('get_field')
->once() ->once()
->with('index_town_alt', 'option') ->with('index_town_alt', 'option')
@@ -334,6 +360,7 @@ final class HelpersTest extends TestCase
public function test_reset_cache_clears_page_unic_cache(): void public function test_reset_cache_clears_page_unic_cache(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
$term = new \WP_Term(); $term = new \WP_Term();
$term->term_id = 11; $term->term_id = 11;
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term); \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
@@ -348,6 +375,48 @@ final class HelpersTest extends TestCase
self::assertTrue(true); self::assertTrue(true);
} }
public function test_main_town_slug_returns_null_before_acf_init(): void
{
\Brain\Monkey\Functions\expect('did_action')
->with('acf/init')
->andReturn(0);
// get_field should NOT be called — no expectation means Brain Monkey throws if it is.
self::assertNull(Helpers::main_town_slug());
}
public function test_main_town_slug_calls_get_field_after_acf_init(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field')
->once()
->with('main_town_slug', 'option')
->andReturn('spb');
self::assertSame('spb', Helpers::main_town_slug());
}
public function test_is_routable_path_returns_true_before_acf_init(): void
{
\Brain\Monkey\Functions\expect('did_action')
->with('acf/init')
->andReturn(0);
// get_field should NOT be called.
self::assertTrue(Helpers::is_routable_path('/some/path/'));
}
public function test_is_routable_path_checks_exclusions_after_acf_init(): void
{
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_field')
->once()
->with('page_no_rep', 'option')
->andReturn([['link' => '/excluded/']]);
self::assertFalse(Helpers::is_routable_path('/excluded/page/'));
}
public function test_is_bypassed_returns_false_by_default(): void public function test_is_bypassed_returns_false_by_default(): void
{ {
self::assertFalse(Helpers::is_bypassed()); self::assertFalse(Helpers::is_bypassed());
+3
View File
@@ -44,6 +44,7 @@ final class LinkFilterTest extends TestCase
{ {
$_SERVER['REQUEST_URI'] = '/' . $town_slug . '/'; $_SERVER['REQUEST_URI'] = '/' . $town_slug . '/';
$_SERVER['HTTP_HOST'] = $host; $_SERVER['HTTP_HOST'] = $host;
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => $town_slug]); \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => $town_slug]);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) use ($main) { \Brain\Monkey\Functions\when('get_field')->alias(function ($name) use ($main) {
if ($name === 'main_town_slug') return $main; if ($name === 'main_town_slug') return $main;
@@ -62,6 +63,7 @@ final class LinkFilterTest extends TestCase
{ {
$_SERVER['REQUEST_URI'] = '/random/'; $_SERVER['REQUEST_URI'] = '/random/';
$_SERVER['HTTP_HOST'] = 'site.com'; $_SERVER['HTTP_HOST'] = 'site.com';
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']); \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
\Brain\Monkey\Functions\when('get_field')->justReturn(false); \Brain\Monkey\Functions\when('get_field')->justReturn(false);
@@ -129,6 +131,7 @@ final class LinkFilterTest extends TestCase
{ {
$_SERVER['REQUEST_URI'] = '/tyumen/'; $_SERVER['REQUEST_URI'] = '/tyumen/';
$_SERVER['HTTP_HOST'] = 'site.com'; $_SERVER['HTTP_HOST'] = 'site.com';
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) { \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb'; if ($name === 'main_town_slug') return 'spb';
+3 -3
View File
@@ -34,9 +34,9 @@ final class PluginBootS4Test extends TestCase
'boot() must register Router on wp@9999' 'boot() must register Router on wp@9999'
); );
self::assertSame( self::assertSame(
9998, 1,
has_action('wp', ['WPMultiCity\\Redirect', 'handle']), has_action('template_redirect', ['WPMultiCity\\Redirect', 'handle']),
'boot() must register Redirect on wp@9998' 'boot() must register Redirect on template_redirect@1'
); );
} }
} }
+2 -2
View File
@@ -74,8 +74,8 @@ final class PluginBootTest extends TestCase
\Brain\Monkey\Actions\expectAdded('acf/init') \Brain\Monkey\Actions\expectAdded('acf/init')
->with([\WPMultiCity\TermMeta::class, 'do_register']) ->with([\WPMultiCity\TermMeta::class, 'do_register'])
->once(); ->once();
\Brain\Monkey\Actions\expectAdded('wp') \Brain\Monkey\Actions\expectAdded('template_redirect')
->with([\WPMultiCity\Redirect::class, 'handle'], 9998) ->with([\WPMultiCity\Redirect::class, 'handle'], 1)
->once(); ->once();
\Brain\Monkey\Actions\expectAdded('wp') \Brain\Monkey\Actions\expectAdded('wp')
->with([\WPMultiCity\Router::class, 'route'], 9999) ->with([\WPMultiCity\Router::class, 'route'], 9999)
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use WPMultiCity\Redirect;
/**
* Isolated test for the DOING_AJAX bail-out in Redirect::handle().
*
* Isolation is achieved via @runInSeparateProcess on the test method, which
* forks a fresh PHP process so that defining the DOING_AJAX constant cannot
* contaminate other tests (e.g. RouterTest). The previous Z-prefix ordering
* hack is no longer needed.
*/
final class RedirectDoingAjaxTest extends TestCase
{
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
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);
}
}
+264 -37
View File
@@ -7,78 +7,97 @@ use WPMultiCity\Redirect;
final class RedirectTest extends TestCase final class RedirectTest extends TestCase
{ {
public function test_register_attaches_wp_action_at_priority_9998(): void public function test_register_attaches_template_redirect_at_priority_1(): void
{ {
Redirect::register(); Redirect::register();
self::assertSame( self::assertSame(
9998, 1,
has_action('wp', [Redirect::class, 'handle']), has_action('template_redirect', [Redirect::class, 'handle']),
'Redirect::register() must hook wp action at priority 9998' 'Redirect::register() must hook template_redirect at priority 1'
); );
} }
public function test_handle_skips_when_current_town_equals_main(): void 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(); \Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/spb/about/';
\Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
return false;
});
Redirect::handle(); Redirect::handle();
self::assertTrue(true); self::assertTrue(true);
} }
public function test_handle_skips_when_post_not_set(): void public function test_bail_when_not_404(): void
{ {
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never(); \Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/about/'; \Brain\Monkey\Functions\when('is_404')->justReturn(false);
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb';
return false;
});
unset($GLOBALS['post']);
Redirect::handle(); Redirect::handle();
self::assertTrue(true); self::assertTrue(true);
} }
public function test_handle_skips_when_url_already_has_town_prefix(): void public function test_bail_when_main_town_slug_null(): void
{ {
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never(); \Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/about/'; \Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) { \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb'; if ($name === 'main_town_slug') return null;
return false; return false;
}); });
$GLOBALS['post'] = (object) ['ID' => 84]; \Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle(); Redirect::handle();
self::assertTrue(true); self::assertTrue(true);
} }
public function test_handle_redirects_when_post_is_page_unic_clone_without_prefix(): void 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_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/about/'; \Brain\Monkey\Functions\when('is_404')->justReturn(true);
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \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();
$term = new \WP_Term(); Redirect::handle();
$term->term_id = 11;
$term->slug = 'tyumen';
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
self::assertTrue(true);
}
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);
}
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) { \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
if ($name === 'main_town_slug') return 'spb'; if ($name === 'main_town_slug') return 'spb';
if ($name === 'page_unic') return [ if ($name === 'page_unic') return [
@@ -86,12 +105,94 @@ final class RedirectTest extends TestCase
]; ];
return false; return false;
}); });
$GLOBALS['post'] = (object) ['ID' => 84]; $_SERVER['REQUEST_URI'] = '/orphan-slug/';
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/about/'); \Brain\Monkey\Functions\when('get_posts')->justReturn([
(object) ['ID' => 999],
]);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
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);
}
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';
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
Redirect::handle();
self::assertTrue(true);
}
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') \Brain\Monkey\Functions\expect('wp_safe_redirect')
->once() ->once()
->with('/tyumen/about/', 301) ->with('/tyumen/tarif/', 301)
->andThrow(new \RuntimeException('redirect-called')); ->andThrow(new \RuntimeException('redirect-called'));
try { try {
@@ -101,4 +202,130 @@ final class RedirectTest extends TestCase
self::assertSame('redirect-called', $e->getMessage()); self::assertSame('redirect-called', $e->getMessage());
} }
} }
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());
}
}
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);
\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());
}
}
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);
$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()'
);
}
} }
+9
View File
@@ -48,6 +48,7 @@ final class RouterTest extends TestCase
public function test_route_skips_when_current_town_is_null(): void public function test_route_skips_when_current_town_is_null(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('url_to_postid')->never(); \Brain\Monkey\Functions\expect('url_to_postid')->never();
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/something-not-a-city/'; $_SERVER['REQUEST_URI'] = '/something-not-a-city/';
@@ -61,6 +62,7 @@ final class RouterTest extends TestCase
public function test_route_skips_when_current_town_equals_main_town(): void public function test_route_skips_when_current_town_equals_main_town(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('url_to_postid')->never(); \Brain\Monkey\Functions\expect('url_to_postid')->never();
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/spb/'; $_SERVER['REQUEST_URI'] = '/spb/';
@@ -77,6 +79,7 @@ final class RouterTest extends TestCase
public function test_route_skips_when_path_not_routable(): void public function test_route_skips_when_path_not_routable(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('url_to_postid')->never(); \Brain\Monkey\Functions\expect('url_to_postid')->never();
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/privacy/'; $_SERVER['REQUEST_URI'] = '/tyumen/privacy/';
@@ -94,6 +97,7 @@ final class RouterTest extends TestCase
public function test_route_uses_main_page_alt_term_meta_for_root_url(): void public function test_route_uses_main_page_alt_term_meta_for_root_url(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/'; $_SERVER['REQUEST_URI'] = '/tyumen/';
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
@@ -123,6 +127,7 @@ final class RouterTest extends TestCase
public function test_route_falls_back_to_index_town_alt_option(): void public function test_route_falls_back_to_index_town_alt_option(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/'; $_SERVER['REQUEST_URI'] = '/tyumen/';
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
@@ -148,6 +153,7 @@ final class RouterTest extends TestCase
public function test_route_does_not_install_query_when_neither_main_nor_fallback_set(): void public function test_route_does_not_install_query_when_neither_main_nor_fallback_set(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_post')->never(); \Brain\Monkey\Functions\expect('get_post')->never();
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/'; $_SERVER['REQUEST_URI'] = '/tyumen/';
@@ -169,6 +175,7 @@ final class RouterTest extends TestCase
public function test_route_resolves_internal_page_via_url_to_postid(): void public function test_route_resolves_internal_page_via_url_to_postid(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/about/'; $_SERVER['REQUEST_URI'] = '/tyumen/about/';
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
@@ -193,6 +200,7 @@ final class RouterTest extends TestCase
public function test_route_applies_page_unic_swap_when_clone_exists(): void public function test_route_applies_page_unic_swap_when_clone_exists(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/about/'; $_SERVER['REQUEST_URI'] = '/tyumen/about/';
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
@@ -220,6 +228,7 @@ final class RouterTest extends TestCase
public function test_route_does_not_install_query_when_url_to_postid_returns_zero(): void public function test_route_does_not_install_query_when_url_to_postid_returns_zero(): void
{ {
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
\Brain\Monkey\Functions\expect('get_post')->never(); \Brain\Monkey\Functions\expect('get_post')->never();
\Brain\Monkey\Functions\when('is_admin')->justReturn(false); \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
$_SERVER['REQUEST_URI'] = '/tyumen/unknown/'; $_SERVER['REQUEST_URI'] = '/tyumen/unknown/';
+2 -2
View File
@@ -2,7 +2,7 @@
/** /**
* Plugin Name: WP Multi City * Plugin Name: WP Multi City
* Description: Мультигородская подсистема: taxonomy town, шорткоды для подстановки по городу, роутер /{city}/{path}/, page-clones по городам. * Description: Мультигородская подсистема: taxonomy town, шорткоды для подстановки по городу, роутер /{city}/{path}/, page-clones по городам.
* Version: 1.0.0 * Version: 1.1.0
* Author: Vladimir Bryzgalov * Author: Vladimir Bryzgalov
* Requires PHP: 8.0 * Requires PHP: 8.0
* Requires at least: 6.0 * Requires at least: 6.0
@@ -18,7 +18,7 @@ if (!defined('ABSPATH')) exit;
defined('WPMC_PLUGIN_FILE') || define('WPMC_PLUGIN_FILE', __FILE__); defined('WPMC_PLUGIN_FILE') || define('WPMC_PLUGIN_FILE', __FILE__);
defined('WPMC_PLUGIN_DIR') || define('WPMC_PLUGIN_DIR', plugin_dir_path(__FILE__)); defined('WPMC_PLUGIN_DIR') || define('WPMC_PLUGIN_DIR', plugin_dir_path(__FILE__));
defined('WPMC_PLUGIN_URL') || define('WPMC_PLUGIN_URL', plugin_dir_url(__FILE__)); defined('WPMC_PLUGIN_URL') || define('WPMC_PLUGIN_URL', plugin_dir_url(__FILE__));
defined('WPMC_VERSION') || define('WPMC_VERSION', '1.0.0'); defined('WPMC_VERSION') || define('WPMC_VERSION', '1.1.0');
require_once WPMC_PLUGIN_DIR . 'includes/class-plugin.php'; require_once WPMC_PLUGIN_DIR . 'includes/class-plugin.php';
\WPMultiCity\Plugin::register_autoloader(); \WPMultiCity\Plugin::register_autoloader();
+4 -4
View File
@@ -1,16 +1,16 @@
{ {
"name": "WP Multi City", "name": "WP Multi City",
"slug": "wp-multi-city", "slug": "wp-multi-city",
"version": "1.0.0", "version": "1.0.1",
"download_url": "https://git.netranking.ru/bryzgalov/wp-multi-city/releases/download/v1.0.0/wp-multi-city-1.0.0.zip", "download_url": "https://git.netranking.ru/bryzgalov/wp-multi-city/releases/download/v1.0.1/wp-multi-city-1.0.1.zip",
"homepage": "https://git.netranking.ru/bryzgalov/wp-multi-city", "homepage": "https://git.netranking.ru/bryzgalov/wp-multi-city",
"requires": "6.0", "requires": "6.0",
"tested": "6.9", "tested": "6.9",
"requires_php": "8.0", "requires_php": "8.0",
"last_updated": "2026-05-17 00:00:00", "last_updated": "2026-05-17 12:02:11",
"author": "Vladimir Bryzgalov", "author": "Vladimir Bryzgalov",
"sections": { "sections": {
"description": "Мультигородская подсистема: taxonomy town, [shortcode_dmr] для подстановок, URL-роутер /{city}/{path}/, ACF-driven page clones.", "description": "Мультигородская подсистема: taxonomy town, [shortcode_dmr] для подстановок, URL-роутер /{city}/{path}/, ACF-driven page clones.",
"changelog": "<h4>1.0.0</h4><ul><li>Первый релиз. MVP-ядро S1–S7.</li></ul>" "changelog": "<h4>1.0.1</h4><ul><li>Add: процедурный wrapper `wpmc_main_town_slug()` (был задокументирован в README, но отсутствовал в коде).</li><li>Fix: WP 6.7+ notice `_load_textdomain_just_in_time` — убраны `__()` обёртки из labels CPT/таксономии/ACF field group (плагин не поставляется с переводами).</li><li>Fix: ACF `acf_get_value` \"called too early\" notice — добавлен `did_action('acf/init')` guard в `Helpers::main_town_slug()`, `index_town_alt_page_id`, `page_unic_swap`, `is_routable_path`, `alt_get_field`. До инициализации ACF возвращают безопасные defaults (null/true).</li></ul><h4>1.0.0</h4><ul><li>Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4).</li><li>taxonomy town + CPT shortcode-town + ACF list_shortcode repeater.</li><li>[shortcode_dmr id=N] для подстановок по городу.</li><li>URL-роутер /{city}/{path}/ с поддержкой page_unic / main_page_alt / page_no_rep.</li><li>Глобальные фильтры the_title, home_url, post_link, acf/load_value, rest_url.</li><li>Self-hosted updates через Gitea + plugin-update-checker v5.6.</li></ul>"
} }
} }