spec(wpmc S3): design doc for shortcode_dmr + helpers

This commit is contained in:
Vladimir Bryzgalov
2026-05-12 19:36:00 +05:00
parent 12a271981a
commit 245c114ec3
@@ -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
Нет. Все решения зафиксированы.