commit fad5a7d03b47fc7eaffc0db647062c08730a349c Author: Vladimir Bryzgalov Date: Tue May 12 17:21:08 2026 +0500 chore: init wp-multi-city v0.1.0 with S1 skeleton + S2 design spec 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46b57c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/vendor/ +/composer.lock +/dist/ +/.phpunit.result.cache +/.idea/ +/.vscode/ +.DS_Store +*.log +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..03e48af --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# WP Multi City + +Мультигородская подсистема для WordPress: один сайт обслуживает несколько городов по схеме `/{city}/{path}/` с подстановкой контента, ссылок и шорткодов. + +**Статус:** v0.1.0 — скелет. Функционал в разработке (см. roadmap ниже). + +## Требования + +- WordPress 6.0+ +- PHP 8.0+ +- Advanced Custom Fields Pro + +## Установка (на v0.1.0) + +1. Скачать архив или склонировать в `wp-content/plugins/wp-multi-city/`. +2. Активировать в админке WP. Если ACF Pro не активен — увидите admin-notice об ошибке зависимости. + +## Roadmap MVP + +| Этап | Содержание | Beads | Статус | +|---|---|---|---| +| S1 | Скелет (header, autoload, bootstrap) | cp-8ci | in progress | +| S2 | Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` | cp-q7g | open | +| S3 | `[shortcode_dmr id=N]` + хелперы (`current_town`, `_alt_get_field`, `remove_domain_link`) | cp-arq | open | +| S4 | URL-роутер `/{city}/{path}/` + `page_unic` + `main_page_alt` | cp-83m | open | +| S5 | Фильтры `the_title`/`the_content`/`the_permalink`/`rest_url` | cp-i17 | open | +| S6 | PHPUnit-тесты | cp-la7 | open | +| S7 | Релиз через Gitea + plugin-update-checker | cp-7cf | open | + +Эпик: `cp-cw4`. + +## Источник + +Плагин — переработка кастомной мультигородской подсистемы темы `washanyanya` (1555 строк `inc/town-functions.php`) в переиспользуемый плагин для других проектов. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0360c70 --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "bryzgalov/wp-multi-city", + "description": "Multi-city subsystem for WordPress: taxonomy town, per-city shortcodes, /{city}/{path}/ router, page clones.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "autoload-dev": { + "psr-4": { + "WPMultiCity\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + } +} diff --git a/docs/superpowers/specs/2026-05-12-s2-taxonomy-cpt-acf-design.md b/docs/superpowers/specs/2026-05-12-s2-taxonomy-cpt-acf-design.md new file mode 100644 index 0000000..888d13f --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-s2-taxonomy-cpt-acf-design.md @@ -0,0 +1,211 @@ +# S2 — Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` + +- Beads: `cp-q7g` (parent epic `cp-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()`: + +```php +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` + +```php +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` + +```php +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` + +```php +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.6` +- `mockery/mockery: ^1.6` +- `phpunit/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 порядок): + +1. **TaxonomyTest::test_register_adds_init_action** — после `Taxonomy::register()` Brain Monkey подтверждает что `add_action('init', ...)` был вызван. +2. **TaxonomyTest::test_init_callback_registers_taxonomy_town** — выполнение callback вызывает `register_taxonomy('town', ['shortcode-town'], $args)` с обязательными ключами (`hierarchical=false`, `show_in_rest=true`, `public=false`). +3. **PostTypeTest::test_register_adds_init_action** — аналогично. +4. **PostTypeTest::test_init_callback_registers_post_type_shortcode_town** — `register_post_type('shortcode-town', $args)` с `public=false`, `show_ui=true`, `supports=['title']`. +5. **FieldGroupTest::test_register_adds_acf_init_action** — после `FieldGroup::register()` Brain Monkey подтверждает `add_action('acf/init', ...)`. +6. **FieldGroupTest::test_acf_init_callback_registers_local_field_group** — `acf_add_local_field_group()` вызывается с key=`group_wpmc_shortcode_rows`, наличием repeater `list_shortcode` и двух sub-fields с правильными `name`/`type`/`taxonomy`/`return_format`. +7. **FieldGroupTest::test_acf_init_callback_noop_when_acf_missing** — если `function_exists('acf_add_local_field_group')` возвращает false, callback не падает. +8. **PluginBootTest::test_boot_invokes_three_registrations** — `Plugin::instance()->boot()` (когда зависимости удовлетворены) вызывает `Taxonomy::register`, `PostType::register`, `FieldGroup::register`. Реализуется через Brain Monkey `Actions\expectAdded`. + +Каждый тест — отдельный RED → GREEN коммит. + +## Acceptance verification (после green-тестов) + +1. `composer install && vendor/bin/phpunit` — все тесты зелёные. +2. Активировать плагин на чистом WP с ACF Pro (можно washanyanya local-копии не нужен — wp-cli + новый WP): + - admin menu показывает «Шорткоды» (CPT) и «Города» (taxonomy в его подменю); + - открыть «Шорткоды → Добавить» — виден repeater «Подстановки по городам» с двумя sub-fields. +3. Деактивация плагина → пункты меню исчезают. + +## Что НЕ входит в 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 + +Нет. Все решения зафиксированы выше. diff --git a/includes/class-activator.php b/includes/class-activator.php new file mode 100644 index 0000000..dbf5ff3 --- /dev/null +++ b/includes/class-activator.php @@ -0,0 +1,18 @@ +dependencies_satisfied()) { + add_action('admin_notices', [$this, 'render_dependency_notice']); + return; + } + + // Stage 2-5 hooks register here. Stage 1 (skeleton) intentionally empty. + } + + private function dependencies_satisfied(): bool + { + return class_exists('ACF') || function_exists('acf_add_local_field_group'); + } + + public function render_dependency_notice(): void + { + if (!current_user_can('activate_plugins')) return; + echo '

WP Multi City: требуется активный плагин Advanced Custom Fields Pro.

'; + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..5276271 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,8 @@ +boot(); +});