spec(wpmc S3): design doc for shortcode_dmr + helpers
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
Нет. Все решения зафиксированы.
|
||||||
Reference in New Issue
Block a user