S1 skeleton (cp-8ci, already done): - wp-multi-city.php with header, constants, hooks bootstrap - includes/class-plugin.php with PSR-4-ish autoloader, ACF dependency guard - includes/class-activator.php placeholder - composer.json (PHP 8.0, phpunit dev) - README.md with roadmap - uninstall.php placeholder S2 design (cp-q7g): - docs/superpowers/specs/2026-05-12-s2-taxonomy-cpt-acf-design.md
10 KiB
S2 — Taxonomy town + CPT shortcode-town + ACF list_shortcode
- Beads:
cp-q7g(parent epiccp-cw4) - Depends on:
cp-8ci(S1 — скелет плагина, закрыт) - Blocks:
cp-arq(S3 —[shortcode_dmr]handler) - Дата: 2026-05-12
Цель
Подсистема хранения для мультигородского контента: справочник городов (taxonomy), внутренний CPT-контейнер «шорткодов», и ACF-репитер строк подстановок «город → значение». Воспроизводит структуру темы washanyanya, но регистрируется только в коде (не через ACF UI) — чтобы плагин активировался на новых сайтах без миграции БД.
Архитектура
Три независимых класса в includes/, каждый со статическим register():
includes/
├── class-plugin.php ← Plugin::boot() вызывает три register()
├── class-activator.php ← без изменений
├── class-taxonomy.php ← WPMultiCity\Taxonomy
├── class-post-type.php ← WPMultiCity\PostType
└── class-field-group.php ← WPMultiCity\FieldGroup
Имена классов совместимы с S1 autoloader (Plugin::register_autoloader):
WPMultiCity\PostType → includes/class-post-type.php, etc.
Plugin::boot()
После проверки dependencies_satisfied():
Taxonomy::register(); // add_action('init', [self::class, 'do_register'])
PostType::register(); // add_action('init', [self::class, 'do_register'])
FieldGroup::register(); // add_action('acf/init', [self::class, 'do_register'])
Каждый register() идемпотентен (вешает свой add_action один раз) и сам по себе ничего не регистрирует — регистрация происходит в hook-callback. Это даёт чистое разделение «когда вешаем хук» (boot) и «что делаем в хуке» (do_register) для тестируемости.
Регистрационные параметры
Taxonomy town
register_taxonomy('town', ['shortcode-town'], [
'labels' => [
'name' => __('Города', 'wp-multi-city'),
'singular_name' => __('Город', 'wp-multi-city'),
'menu_name' => __('Города', 'wp-multi-city'),
'add_new_item' => __('Добавить', 'wp-multi-city'),
],
'public' => false,
'publicly_queryable' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => false,
'show_admin_column' => false,
'show_in_rest' => true,
'query_var' => false,
'rewrite' => false,
'meta_box_cb' => false,
]);
Решения:
hierarchical=false— города плоские (acceptance cp-q7g).public=false+publicly_queryable=false— нет publik /town// архива; роутер /{city}/{path}/ строится отдельно в S4.show_in_rest=true— на случай Gutenberg-блоков и REST API клиентов.meta_box_cb=false— на CPT edit-screen мета-бокс таксы не нужен (term выбирается в ACF repeater подстановок).rewrite=false— никаких URL-конфликтов с роутером S4.
CPT shortcode-town
register_post_type('shortcode-town', [
'labels' => [
'name' => __('Шорткоды', 'wp-multi-city'),
'singular_name' => __('Шорткод', 'wp-multi-city'),
'menu_name' => __('Шорткоды', 'wp-multi-city'),
'add_new' => __('Добавить', 'wp-multi-city'),
'add_new_item' => __('Добавить шорткод', 'wp-multi-city'),
'edit_item' => __('Редактировать', 'wp-multi-city'),
'new_item' => __('Новый', 'wp-multi-city'),
'view_item' => __('Смотреть', 'wp-multi-city'),
'search_items' => __('Поиск', 'wp-multi-city'),
'not_found' => __('Не найдено', 'wp-multi-city'),
],
'public' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => false,
'show_in_rest' => false,
'query_var' => false,
'has_archive' => false,
'rewrite' => false,
'hierarchical' => false,
'capability_type' => 'post',
'supports' => ['title'],
'menu_position' => 8,
]);
CPT — служебный контейнер. show_in_rest=false (acceptance молчит, держим minimal scope).
ACF group WPMultiCity / Shortcode rows
acf_add_local_field_group([
'key' => 'group_wpmc_shortcode_rows',
'title' => 'WPMultiCity / Shortcode rows',
'fields' => [
[
'key' => 'field_wpmc_list_shortcode',
'label' => __('Подстановки по городам', 'wp-multi-city'),
'name' => 'list_shortcode',
'type' => 'repeater',
'layout' => 'table',
'button_label' => __('Добавить город', 'wp-multi-city'),
'sub_fields' => [
[
'key' => 'field_wpmc_list_shortcode_town',
'label' => __('Город', 'wp-multi-city'),
'name' => 'town',
'type' => 'taxonomy',
'taxonomy' => 'town',
'field_type' => 'select',
'return_format' => 'id',
'allow_null' => 0,
'multiple' => 0,
'add_term' => 0,
],
[
'key' => 'field_wpmc_list_shortcode_text',
'label' => __('Значение', 'wp-multi-city'),
'name' => 'text',
'type' => 'textarea',
'rows' => 4,
],
],
],
],
'location' => [
[
['param' => 'post_type', 'operator' => '==', 'value' => 'shortcode-town'],
],
],
'menu_order' => 0,
'position' => 'normal',
'style' => 'default',
'active' => true,
]);
Под-поле town:
return_format=id—get_field('list_shortcode', $id)отдаёт массив со строками вида['town' => 42, 'text' => '...']. Это то, что ждёт S3 (add_shortcode_dmrна washanyanya читает$item['town']как ID и затемget_term($item['town'], 'town')).allow_null=0— пустой город в строке репитера бессмыслен.
Тестирование (Brain Monkey + Mockery)
Composer dev-deps:
brain/monkey: ^2.6mockery/mockery: ^1.6phpunit/phpunit: ^9.6(уже есть)
Структура:
tests/
├── bootstrap.php ← require vendor/autoload.php
├── TestCase.php ← Brain\Monkey\setUp/tearDown + Mockery\close()
├── TaxonomyTest.php
├── PostTypeTest.php
├── FieldGroupTest.php
└── PluginBootTest.php
phpunit.xml.dist
Тестовые сценарии (RED → GREEN порядок):
- TaxonomyTest::test_register_adds_init_action — после
Taxonomy::register()Brain Monkey подтверждает чтоadd_action('init', ...)был вызван. - TaxonomyTest::test_init_callback_registers_taxonomy_town — выполнение callback вызывает
register_taxonomy('town', ['shortcode-town'], $args)с обязательными ключами (hierarchical=false,show_in_rest=true,public=false). - PostTypeTest::test_register_adds_init_action — аналогично.
- PostTypeTest::test_init_callback_registers_post_type_shortcode_town —
register_post_type('shortcode-town', $args)сpublic=false,show_ui=true,supports=['title']. - FieldGroupTest::test_register_adds_acf_init_action — после
FieldGroup::register()Brain Monkey подтверждаетadd_action('acf/init', ...). - FieldGroupTest::test_acf_init_callback_registers_local_field_group —
acf_add_local_field_group()вызывается с key=group_wpmc_shortcode_rows, наличием repeaterlist_shortcodeи двух sub-fields с правильнымиname/type/taxonomy/return_format. - FieldGroupTest::test_acf_init_callback_noop_when_acf_missing — если
function_exists('acf_add_local_field_group')возвращает false, callback не падает. - PluginBootTest::test_boot_invokes_three_registrations —
Plugin::instance()->boot()(когда зависимости удовлетворены) вызываетTaxonomy::register,PostType::register,FieldGroup::register. Реализуется через Brain MonkeyActions\expectAdded.
Каждый тест — отдельный RED → GREEN коммит.
Acceptance verification (после green-тестов)
composer install && vendor/bin/phpunit— все тесты зелёные.- Активировать плагин на чистом WP с ACF Pro (можно washanyanya local-копии не нужен — wp-cli + новый WP):
- admin menu показывает «Шорткоды» (CPT) и «Города» (taxonomy в его подменю);
- открыть «Шорткоды → Добавить» — виден repeater «Подстановки по городам» с двумя sub-fields.
- Деактивация плагина → пункты меню исчезают.
Что НЕ входит в S2
- Term-meta
main_page_altна term'е town — S4 (cp-83m). - Shortcode
[shortcode_dmr]handler + хелперыcurrent_town,_alt_get_field,remove_domain_link— S3 (cp-arq). - Term ACF
procent(цены) — out of MVP. - Полный WP integration test suite — S6 (cp-la7).
Open questions
Нет. Все решения зафиксированы выше.