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

11 KiB
Raw Blame History

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

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

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)

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

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.commailto:x@y.com (no-op)
  • tel:+79991234567tel:+79991234567 (no-op)

Plugin::boot() — изменение

После S2-регистраций (Taxonomy/PostType/FieldGroup) добавить:

ShortcodeDmr::register();

Helpers сам по себе не регистрирует никаких хуков — это статический utility-класс.

wp-multi-city.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():

\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@ymailto:x@y (no-op).
  17. remove_domain_link: невалидный URL → возвращает как есть.

tests/ShortcodeDmrTest.php:

  1. register вешает init action с do_register.
  2. do_register вызывает add_shortcode('shortcode_dmr', ...).
  3. render с id=0 → ''.
  4. render когда current_town() = null → ''.
  5. render когда term не найден → ''.
  6. render когда rows = [] → ''.
  7. render когда matching row → возвращает text.
  8. render когда no matching row → ''.
  9. render — второй вызов с теми же $atts → не зовёт get_field повторно (cache).

tests/PluginBootShortcodeDmrTest.php:

  1. 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

Нет. Все решения зафиксированы.