# S3 — `[shortcode_dmr]` + хелперы `wpmc_current_town` / `wpmc_alt_get_field` / `wpmc_remove_domain_link` / `wpmc_get_term_by_slug` - Beads: `cp-arq` (parent epic `cp-cw4`) - Depends on: `cp-q7g` (S2 — taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode`, закрыт) - Blocks: `cp-83m` (S4 — URL-роутер `/{city}/{path}/`) - Дата: 2026-05-12 ## Цель Ядро подстановки контента по городу. Тема вызывает четыре публичные wpmc-функции и шорткод `[shortcode_dmr id=N]`; плагин разрешает значение для текущего города из ACF-репитера `list_shortcode` поста-контейнера. Версия S3 не зависит от URL-роутера (S4) — `current_town()` работает через парсинг `REQUEST_URI` напрямую. S4 потом подключит свою query-var логику через фильтр `wpmc_current_town_slug`. ## Архитектура ``` includes/ ├── class-helpers.php ← WPMultiCity\Helpers ├── class-shortcode-dmr.php ← WPMultiCity\ShortcodeDmr └── wpmc-functions.php ← процедурный API (тонкие обёртки) wp-multi-city.php ← require_once wpmc-functions.php (unconditional) includes/class-plugin.php ← boot() добавляет ShortcodeDmr::register() ``` `Helpers` — статический класс с методами реализации. `ShortcodeDmr` — статический класс с `register()` (вешает `add_shortcode` на init) и handler-методом. `wpmc-functions.php` — глобальные функции `wpmc_*`, каждая в одну строку вызывает соответствующий метод `Helpers`. Загрузка процедурного API — безусловно в главном файле плагина (до проверки ACF), чтобы `function_exists('wpmc_current_town')` в шаблонах темы всегда возвращал `true`. ## Публичный API ```php function wpmc_current_town(): ?string; function wpmc_get_term_by_slug(string $slug, string $taxonomy = 'town'): ?WP_Term; function wpmc_alt_get_field(string $selector, $post_id = false, bool $format_value = true); function wpmc_remove_domain_link(string $url): string; ``` Shortcode: `[shortcode_dmr id=N]`. ## `Helpers::current_town(): ?string` Логика: 1. Per-request memoization — если уже определено, вернуть закэшированное. 2. Распарсить `$_SERVER['REQUEST_URI']`: разбить по `/`, отбросить пустые сегменты, взять первый. 3. Если первый сегмент совпадает со slug существующего term'а `town` (через `get_terms('town', ['fields' => 'id=>slug', 'hide_empty' => false])`) → resolved = этот slug. 4. Иначе → resolved = `apply_filters('wpmc_main_town_slug', null)` (тема выставляет дефолт). 5. `resolved = apply_filters('wpmc_current_town_slug', $resolved)` — extension-point для S4. 6. Сохранить в memo, вернуть. Возвращает `null` если ни один из шагов 3-5 не дал значение (т.е. URI не совпадает с термом, фильтры пусты). ## `Helpers::get_term_by_slug(string $slug, string $taxonomy = 'town'): ?WP_Term` ```php private static array $term_cache = []; public static function get_term_by_slug(string $slug, string $taxonomy = 'town'): ?WP_Term { $key = $taxonomy . '|' . $slug; if (array_key_exists($key, self::$term_cache)) { return self::$term_cache[$key]; } $term = get_term_by('slug', $slug, $taxonomy); return self::$term_cache[$key] = ($term instanceof \WP_Term) ? $term : null; } ``` ## `ShortcodeDmr` — handler `[shortcode_dmr id=N]` ``` 1. shortcode_atts(['id' => 0], $atts); $id = (int) $atts['id']; if ($id <= 0) return ''; 2. $town = Helpers::current_town(); if ($town === null) return ''; 3. $term = Helpers::get_term_by_slug($town); if ($term === null) return ''; 4. Per-request cache key = $id . '|' . $town. Если есть — вернуть. 5. $rows = get_field('list_shortcode', $id); if (empty($rows) || !is_array($rows)) cache and return ''. 6. foreach ($rows as $row): if ((int) $row['town'] === $term->term_id) { result = (string) $row['text']; break; } 7. Save to cache and return. ``` Регистрация: `add_action('init', [self::class, 'do_register'])` → внутри `add_shortcode('shortcode_dmr', [self::class, 'render'])`. ## `Helpers::alt_get_field($selector, $post_id, $format_value)` ```php public static function alt_get_field(string $selector, $post_id = false, bool $format_value = true) { $post_id = apply_filters('wpmc_alt_get_field_post_id', $post_id, $selector); if (!function_exists('get_field')) { return null; } $value = get_field($selector, $post_id, $format_value); return is_string($value) ? do_shortcode($value) : $value; } ``` S4 подключит свой фильтр `wpmc_alt_get_field_post_id` для clone→original маппинга. В S3 фильтр без подписчиков — `$post_id` передаётся через `get_field` без изменений. `function_exists('get_field')` guard — на случай если ACF деактивирован после загрузки темы. ## `Helpers::remove_domain_link(string $url): string` ```php public static function remove_domain_link(string $url): string { $parsed = parse_url($url); if ($parsed === false || empty($parsed['scheme'])) { return $url; } $scheme = strtolower($parsed['scheme']); if ($scheme !== 'http' && $scheme !== 'https') { return $url; } $path = $parsed['path'] ?? ''; $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; return $path . $query . $fragment; } ``` Поведение: - `https://site.com/foo/bar/` → `/foo/bar/` - `http://site.com/foo?q=1#a` → `/foo?q=1#a` - `/foo/bar/` (без schemе) → `/foo/bar/` (no-op) - `mailto:x@y.com` → `mailto:x@y.com` (no-op) - `tel:+79991234567` → `tel:+79991234567` (no-op) ## Plugin::boot() — изменение После S2-регистраций (Taxonomy/PostType/FieldGroup) добавить: ```php ShortcodeDmr::register(); ``` `Helpers` сам по себе не регистрирует никаких хуков — это статический utility-класс. ## wp-multi-city.php — изменение Добавить: ```php require_once WPMC_PLUGIN_DIR . 'includes/wpmc-functions.php'; ``` После строки с `register_autoloader()` и до `plugins_loaded` add_action. Безусловно — функции должны быть доступны теме даже если ACF отсутствует. ## Тестовый стек PHPUnit 9.6 + Brain Monkey 2.7 + Mockery 1.6 + Patchwork (уже настроены в S2). Дополнительно в `tests/TestCase.php::setUp()` после `Monkey\setUp()`: ```php \WPMultiCity\Helpers::reset_cache(); \WPMultiCity\ShortcodeDmr::reset_cache(); ``` Каждый класс выставит публичный `reset_cache()` метод (только для тестов; не используется в production-коде, но не помечается @internal — это легитимная точка очистки). `patchwork.json` уже разрешает редефайн `function_exists` (нужен для теста `alt_get_field` без ACF). ## Тестовые сценарии `tests/HelpersTest.php`: 1. `current_town`: REQUEST_URI=`/tyumen/`, term `tyumen` exists → `'tyumen'`. 2. `current_town`: REQUEST_URI=`/something-not-a-city/`, нет main_town filter → `null`. 3. `current_town`: REQUEST_URI=`/`, filter `wpmc_main_town_slug` → `'spb'` → `'spb'`. 4. `current_town`: REQUEST_URI=`/tyumen/`, term `tyumen` exists, main_town=`'spb'` → `'tyumen'` (URI выигрывает). 5. `current_town`: фильтр `wpmc_current_town_slug` перекрывает результат → `'override'`. 6. `current_town`: per-request memo — второй вызов не зовёт `get_terms` повторно. 7. `get_term_by_slug`: первый вызов вызывает `get_term_by`, второй — нет (cache). 8. `get_term_by_slug`: term не найден → `null` (и null закэширован). 9. `alt_get_field`: string value → `do_shortcode` применён. 10. `alt_get_field`: array value → возвращён без `do_shortcode`. 11. `alt_get_field`: фильтр `wpmc_alt_get_field_post_id` подменяет $post_id. 12. `alt_get_field`: `function_exists('get_field')` false → `null`. 13. `remove_domain_link`: `https://site.com/foo/` → `/foo/`. 14. `remove_domain_link`: `https://site.com/foo?q=1#a` → `/foo?q=1#a`. 15. `remove_domain_link`: `/foo/` → `/foo/` (no-op). 16. `remove_domain_link`: `mailto:x@y` → `mailto:x@y` (no-op). 17. `remove_domain_link`: невалидный URL → возвращает как есть. `tests/ShortcodeDmrTest.php`: 18. `register` вешает `init` action с `do_register`. 19. `do_register` вызывает `add_shortcode('shortcode_dmr', ...)`. 20. `render` с id=0 → `''`. 21. `render` когда `current_town()` = null → `''`. 22. `render` когда term не найден → `''`. 23. `render` когда rows = [] → `''`. 24. `render` когда matching row → возвращает `text`. 25. `render` когда no matching row → `''`. 26. `render` — второй вызов с теми же $atts → не зовёт `get_field` повторно (cache). `tests/PluginBootShortcodeDmrTest.php`: 27. `Plugin::boot()` вешает `ShortcodeDmr::do_register` на init. Финал: 27 новых тестов + 9 предыдущих = 36 тестов суммарно. ## Acceptance verification (после green-тестов) 1. `vendor/bin/phpunit` → 36 тестов зелёные, 0 risky/warning. 2. Активировать плагин на чистом WP с ACF Pro + темой, в которой шаблон вызывает `wpmc_current_town()` и `[shortcode_dmr id=42]`. 3. Создать таксон `town` со slug'ами `spb`, `tyumen`. 4. Создать `shortcode-town` пост ID=42 с одной строкой `list_shortcode`: town=`tyumen`, text=`Тюмень-текст`. 5. Открыть `/tyumen/` → шорткод должен отдать `Тюмень-текст`. Открыть `/` → `''` (без filter'а). С `add_filter('wpmc_main_town_slug', fn() => 'spb')` и второй row town=`spb`, text=`СПб-текст` → `/` отдаёт `СПб-текст`. ## Что НЕ входит в S3 - query-var `town` и URL-роутер `/{city}/{path}/` — S4 (cp-83m). - `page_unic` / clone-to-original mapping — S4. - Yoast/CF7/sitemap фильтры — out of MVP. - Admin UI / settings page для main_town slug — используем фильтр. ## Open questions Нет. Все решения зафиксированы.