diff --git a/docs/superpowers/specs/2026-05-12-s3-shortcode-dmr-helpers-design.md b/docs/superpowers/specs/2026-05-12-s3-shortcode-dmr-helpers-design.md new file mode 100644 index 0000000..2156755 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-s3-shortcode-dmr-helpers-design.md @@ -0,0 +1,220 @@ +# 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 + +Нет. Все решения зафиксированы.