Files
wp-multi-city/docs/superpowers/specs/2026-05-12-s3-shortcode-dmr-helpers-design.md
T
2026-05-12 19:36:00 +05:00

221 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
Нет. Все решения зафиксированы.