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
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
/vendor/
|
||||||
|
/composer.lock
|
||||||
|
/dist/
|
||||||
|
/.phpunit.result.cache
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.zip
|
||||||
@@ -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`) в переиспользуемый плагин для других проектов.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<slug>/ архива; роутер /{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
|
||||||
|
|
||||||
|
Нет. Все решения зафиксированы выше.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity;
|
||||||
|
|
||||||
|
final class Activator
|
||||||
|
{
|
||||||
|
public static function activate(): void
|
||||||
|
{
|
||||||
|
// Stage 2 will register taxonomy + CPT; flush after they're declared.
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deactivate(): void
|
||||||
|
{
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity;
|
||||||
|
|
||||||
|
final class Plugin
|
||||||
|
{
|
||||||
|
private static ?Plugin $instance = null;
|
||||||
|
|
||||||
|
public static function instance(): Plugin
|
||||||
|
{
|
||||||
|
if (self::$instance === null) self::$instance = new self();
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_autoloader(): void
|
||||||
|
{
|
||||||
|
spl_autoload_register(static function (string $class): void {
|
||||||
|
if (strpos($class, 'WPMultiCity\\') !== 0) return;
|
||||||
|
$relative = substr($class, strlen('WPMultiCity\\'));
|
||||||
|
$slug = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $relative));
|
||||||
|
$slug = str_replace('_', '-', $slug);
|
||||||
|
$candidates = [
|
||||||
|
dirname(__DIR__) . '/includes/class-' . $slug . '.php',
|
||||||
|
dirname(__DIR__) . '/includes/' . $slug . '.php',
|
||||||
|
dirname(__DIR__) . '/admin/class-' . $slug . '.php',
|
||||||
|
];
|
||||||
|
foreach ($candidates as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
require_once $path;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if (!$this->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 '<div class="notice notice-error"><p><strong>WP Multi City:</strong> требуется активный плагин Advanced Custom Fields Pro.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (!defined('WP_UNINSTALL_PLUGIN')) exit;
|
||||||
|
|
||||||
|
// Stage 8+ (uninstall): drop taxonomy terms, CPT posts, options.
|
||||||
|
// MVP keeps user data — uninstall only flushes rewrite rules.
|
||||||
|
flush_rewrite_rules();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: WP Multi City
|
||||||
|
* Description: Мультигородская подсистема: taxonomy town, шорткоды для подстановки по городу, роутер /{city}/{path}/, page-clones по городам.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Author: Vladimir Bryzgalov
|
||||||
|
* Requires PHP: 8.0
|
||||||
|
* Requires at least: 6.0
|
||||||
|
* License: GPLv2 or later
|
||||||
|
* Text Domain: wp-multi-city
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
define('WPMC_PLUGIN_FILE', __FILE__);
|
||||||
|
define('WPMC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
|
define('WPMC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
define('WPMC_VERSION', '0.1.0');
|
||||||
|
|
||||||
|
require_once WPMC_PLUGIN_DIR . 'includes/class-plugin.php';
|
||||||
|
\WPMultiCity\Plugin::register_autoloader();
|
||||||
|
|
||||||
|
register_activation_hook(__FILE__, ['\\WPMultiCity\\Activator', 'activate']);
|
||||||
|
register_deactivation_hook(__FILE__, ['\\WPMultiCity\\Activator', 'deactivate']);
|
||||||
|
|
||||||
|
add_action('plugins_loaded', static function (): void {
|
||||||
|
\WPMultiCity\Plugin::instance()->boot();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user