From c1db5c7712980dcc5e073ac1fec1dca9c84438dd Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Tue, 12 May 2026 17:49:09 +0500 Subject: [PATCH] docs(wpmc S2): add implementation plan for taxonomy/CPT/ACF tasks --- .../plans/2026-05-12-s2-taxonomy-cpt-acf.md | 1095 +++++++++++++++++ 1 file changed, 1095 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-s2-taxonomy-cpt-acf.md diff --git a/docs/superpowers/plans/2026-05-12-s2-taxonomy-cpt-acf.md b/docs/superpowers/plans/2026-05-12-s2-taxonomy-cpt-acf.md new file mode 100644 index 0000000..4830815 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-s2-taxonomy-cpt-acf.md @@ -0,0 +1,1095 @@ +# S2 — Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Register WordPress taxonomy `town`, CPT `shortcode-town`, and ACF local field group `list_shortcode` inside the WP Multi City plugin, fully tested via Brain Monkey unit tests. + +**Architecture:** Three independent classes (`Taxonomy`, `PostType`, `FieldGroup`) each with a static `register()` that hooks WP `init` (or `acf/init`) and a static do-method that performs the registration. `Plugin::boot()` invokes the three `register()` calls when ACF is active. + +**Tech Stack:** PHP 8.0+, WordPress 6.0+, ACF Pro, Composer, PHPUnit 9.6, Brain Monkey 2.6, Mockery 1.6. + +**Spec:** `docs/superpowers/specs/2026-05-12-s2-taxonomy-cpt-acf-design.md` + +**Beads task:** `cp-q7g` (epic `cp-cw4`). + +--- + +## File Structure + +``` +wp-multi-city/ +├── wp-multi-city.php # (existing, unchanged) +├── composer.json # MODIFY — add brain/monkey, mockery +├── phpunit.xml.dist # CREATE — PHPUnit config +├── includes/ +│ ├── class-plugin.php # MODIFY — boot() invokes three register() +│ ├── class-activator.php # (existing, unchanged) +│ ├── class-taxonomy.php # CREATE — WPMultiCity\Taxonomy +│ ├── class-post-type.php # CREATE — WPMultiCity\PostType +│ └── class-field-group.php # CREATE — WPMultiCity\FieldGroup +└── tests/ + ├── bootstrap.php # CREATE — Composer autoload + ├── TestCase.php # CREATE — Brain Monkey setUp/tearDown + ├── TaxonomyTest.php # CREATE + ├── PostTypeTest.php # CREATE + ├── FieldGroupTest.php # CREATE + └── PluginBootTest.php # CREATE +``` + +Boundary rules: +- One class per WP-registration concern, ≤80 lines each. +- Static methods only — no instance state. Easier to mock + assert. +- All Russian-language strings wrapped in `__('…', 'wp-multi-city')`. + +--- + +## Working Directory + +All paths in this plan are relative to: +``` +/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city +``` + +For shell snippets, run them from that directory (the path is shell-quoted in commands). + +--- + +## Task 1: Composer dev dependencies + PHPUnit config + +**Files:** +- Modify: `composer.json` +- Create: `phpunit.xml.dist` +- Create: `tests/bootstrap.php` +- Create: `tests/TestCase.php` + +- [ ] **Step 1.1: Update composer.json — add brain/monkey + mockery, autoload-dev path** + +Final `composer.json`: + +```json +{ + "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": { + "brain/monkey": "^2.6", + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^9.6" + }, + "autoload-dev": { + "psr-4": { + "WPMultiCity\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + } +} +``` + +- [ ] **Step 1.2: Install dependencies** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +composer install +``` + +Expected: composer.lock created, `vendor/` populated, brain/monkey + mockery + phpunit downloaded. No errors. + +- [ ] **Step 1.3: Create phpunit.xml.dist** + +`phpunit.xml.dist`: + +```xml + + + + + tests + tests/bootstrap.php + tests/TestCase.php + + + +``` + +- [ ] **Step 1.4: Create tests/bootstrap.php** + +`tests/bootstrap.php`: + +```php +once() + ->with('town', ['shortcode-town'], \Mockery::on(function ($args) use (&$captured) { + $captured = $args; + return true; + })); + + Taxonomy::do_register(); + + self::assertFalse($captured['hierarchical'], 'town must be flat'); + self::assertFalse($captured['public'], 'town must not be public'); + self::assertTrue($captured['show_ui'], 'town must show in admin UI'); + self::assertTrue($captured['show_in_rest'], 'town must be REST-exposed'); + self::assertFalse($captured['publicly_queryable']); + self::assertFalse($captured['query_var']); + self::assertFalse($captured['rewrite']); + self::assertFalse($captured['meta_box_cb']); + self::assertSame('Города', $captured['labels']['name']); + } +``` + +- [ ] **Step 4.2: Run — expect failure (do_register is empty, register_taxonomy never called)** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit --filter test_do_register_calls_register_taxonomy_town_with_required_args +``` + +Expected: FAIL — Mockery exception "expected register_taxonomy to be called 1 time, called 0 times". + +--- + +## Task 5: Taxonomy `town` — GREEN (real registration body) + +**Files:** +- Modify: `includes/class-taxonomy.php` + +- [ ] **Step 5.1: Replace empty do_register() with full registration** + +Replace the `do_register()` body in `includes/class-taxonomy.php`: + +```php + public static function do_register(): void + { + register_taxonomy(self::SLUG, ['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, + ]); + } +``` + +- [ ] **Step 5.2: Run — expect both Taxonomy tests pass** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit tests/TaxonomyTest.php +``` + +Expected: OK (2 tests, ≥10 assertions). + +- [ ] **Step 5.3: Commit** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +git add tests/TaxonomyTest.php includes/class-taxonomy.php +git commit -m "feat(wpmc S2): register taxonomy town with required args" +``` + +--- + +## Task 6: PostType `shortcode-town` — RED test (init hook attached) + +**Files:** +- Create: `tests/PostTypeTest.php` + +- [ ] **Step 6.1: Write failing test** + +`tests/PostTypeTest.php`: + +```php +once() + ->with('shortcode-town', \Mockery::on(function ($args) use (&$captured) { + $captured = $args; + return true; + })); + + PostType::do_register(); + + self::assertFalse($captured['public'], 'CPT must be non-public'); + self::assertTrue($captured['show_ui'], 'CPT must show in admin'); + self::assertSame(['title'], $captured['supports']); + self::assertTrue($captured['exclude_from_search']); + self::assertFalse($captured['publicly_queryable']); + self::assertFalse($captured['has_archive']); + self::assertFalse($captured['rewrite']); + self::assertFalse($captured['hierarchical']); + self::assertSame(8, $captured['menu_position']); + self::assertSame('Шорткоды', $captured['labels']['name']); + } +``` + +- [ ] **Step 8.2: Run — expect failure (register_post_type not called)** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit --filter "PostTypeTest::test_do_register_calls_register_post_type_shortcode_town_with_required_args" +``` + +Expected: FAIL — register_post_type expected 1 time, called 0 times. + +--- + +## Task 9: PostType `shortcode-town` — GREEN (real registration body) + +**Files:** +- Modify: `includes/class-post-type.php` + +- [ ] **Step 9.1: Replace empty do_register() with full registration** + +Replace `do_register()` body: + +```php + public static function do_register(): void + { + register_post_type(self::SLUG, [ + '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, + ]); + } +``` + +- [ ] **Step 9.2: Run — expect all PostType tests pass** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit tests/PostTypeTest.php +``` + +Expected: OK (2 tests, ≥11 assertions). + +- [ ] **Step 9.3: Commit** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +git add tests/PostTypeTest.php includes/class-post-type.php +git commit -m "feat(wpmc S2): register CPT shortcode-town with required args" +``` + +--- + +## Task 10: FieldGroup — RED test (acf/init hook attached) + +**Files:** +- Create: `tests/FieldGroupTest.php` + +- [ ] **Step 10.1: Write failing test** + +`tests/FieldGroupTest.php`: + +```php +alias(function ($name) { + return $name !== 'acf_add_local_field_group'; + }); + \Brain\Monkey\Functions\expect('acf_add_local_field_group')->never(); + + FieldGroup::do_register(); + + // No assertion needed beyond Mockery's verification of ->never(). + self::assertTrue(true); + } +``` + +- [ ] **Step 12.2: Run — expect failure** + +Currently `do_register()` is empty — `function_exists()` is mocked but never consulted. The test passes by accident. Add the active assertion that forces an early-return path: + +Refine the test (replace the body added in 12.1) so that the assertion verifies the actual code path. Use this version instead: + +```php + public function test_do_register_is_noop_when_acf_missing(): void + { + \Brain\Monkey\Functions\when('function_exists')->justReturn(false); + \Brain\Monkey\Functions\expect('acf_add_local_field_group')->never(); + + FieldGroup::do_register(); + + self::assertTrue(true, 'do_register must not call acf_add_local_field_group when ACF is missing'); + } +``` + +Run: + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit --filter "FieldGroupTest::test_do_register_is_noop_when_acf_missing" +``` + +Expected: PASS (current empty do_register satisfies the never() expectation). This test exists to guard regression once we add the real body in Task 13 — it will then fail if we forget the guard. + +(Note: this is the rare TDD case where the RED step is implicit — the real RED for this contract surfaces only after Task 13 adds the registration call. Step 12 locks the invariant in.) + +- [ ] **Step 12.3: Commit (test-only)** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +git add tests/FieldGroupTest.php +git commit -m "test(wpmc S2): lock ACF-missing guard for FieldGroup" +``` + +--- + +## Task 13: FieldGroup — RED test (acf_add_local_field_group called with correct structure) + +**Files:** +- Modify: `tests/FieldGroupTest.php` + +- [ ] **Step 13.1: Add test verifying the registered group structure** + +Append to `tests/FieldGroupTest.php`: + +```php + public function test_do_register_calls_acf_add_local_field_group_with_expected_structure(): void + { + \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) { + return $name === 'acf_add_local_field_group'; + }); + + $captured = null; + \Brain\Monkey\Functions\expect('acf_add_local_field_group') + ->once() + ->with(\Mockery::on(function ($group) use (&$captured) { + $captured = $group; + return true; + })); + + FieldGroup::do_register(); + + self::assertSame('group_wpmc_shortcode_rows', $captured['key']); + self::assertSame('WPMultiCity / Shortcode rows', $captured['title']); + + self::assertCount(1, $captured['fields']); + $repeater = $captured['fields'][0]; + + self::assertSame('field_wpmc_list_shortcode', $repeater['key']); + self::assertSame('list_shortcode', $repeater['name']); + self::assertSame('repeater', $repeater['type']); + self::assertCount(2, $repeater['sub_fields']); + + [$town, $text] = $repeater['sub_fields']; + self::assertSame('town', $town['name']); + self::assertSame('taxonomy', $town['type']); + self::assertSame('town', $town['taxonomy']); + self::assertSame('id', $town['return_format']); + self::assertSame(0, $town['allow_null']); + + self::assertSame('text', $text['name']); + self::assertSame('textarea', $text['type']); + + self::assertSame( + [[['param' => 'post_type', 'operator' => '==', 'value' => 'shortcode-town']]], + $captured['location'] + ); + } +``` + +- [ ] **Step 13.2: Run — expect failure (do_register is empty, acf_add_local_field_group never called)** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit --filter "FieldGroupTest::test_do_register_calls_acf_add_local_field_group_with_expected_structure" +``` + +Expected: FAIL — acf_add_local_field_group expected 1 time, called 0 times. + +--- + +## Task 14: FieldGroup — GREEN (real registration body) + +**Files:** +- Modify: `includes/class-field-group.php` + +- [ ] **Step 14.1: Replace empty do_register() with full ACF group registration** + +Replace `do_register()` body in `includes/class-field-group.php`: + +```php + public static function do_register(): void + { + if (!function_exists('acf_add_local_field_group')) { + return; + } + + acf_add_local_field_group([ + 'key' => self::GROUP_KEY, + 'title' => 'WPMultiCity / Shortcode rows', + 'fields' => [ + [ + 'key' => self::REPEATER_KEY, + '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, + ]); + } +``` + +- [ ] **Step 14.2: Run all FieldGroup tests — expect all pass** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit tests/FieldGroupTest.php +``` + +Expected: OK (3 tests, ≥14 assertions). + +- [ ] **Step 14.3: Commit** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +git add tests/FieldGroupTest.php includes/class-field-group.php +git commit -m "feat(wpmc S2): register ACF group list_shortcode for shortcode-town" +``` + +--- + +## Task 15: Plugin::boot() — RED test (boot wires up the three registrations) + +**Files:** +- Create: `tests/PluginBootTest.php` + +- [ ] **Step 15.1: Write failing test** + +`tests/PluginBootTest.php`: + +```php +boot(); + + self::assertNotFalse( + has_action('init', ['WPMultiCity\\Taxonomy', 'do_register']), + 'boot() must register Taxonomy on init' + ); + self::assertNotFalse( + has_action('init', ['WPMultiCity\\PostType', 'do_register']), + 'boot() must register PostType on init' + ); + self::assertNotFalse( + has_action('acf/init', ['WPMultiCity\\FieldGroup', 'do_register']), + 'boot() must register FieldGroup on acf/init' + ); + } +} +``` + +Note: `Plugin::instance()` is a singleton — tests share it. The Brain Monkey `Actions` registry is reset between tests via `Monkey\tearDown()`, so duplicate `add_action` calls across tests are harmless within a single test run. + +- [ ] **Step 15.2: Run — expect failure** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit --filter "PluginBootTest::test_boot_attaches_init_and_acf_init_hooks_when_acf_present" +``` + +Expected: FAIL — `has_action` returns false because `Plugin::boot()` currently has empty body (only the dependency check + comment). + +--- + +## Task 16: Plugin::boot() — GREEN (call the three register() methods) + +**Files:** +- Modify: `includes/class-plugin.php:37-45` (boot method body) + +- [ ] **Step 16.1: Update boot() to invoke the three register() methods** + +Replace the `boot()` method body in `includes/class-plugin.php`: + +```php + public function boot(): void + { + if (!$this->dependencies_satisfied()) { + add_action('admin_notices', [$this, 'render_dependency_notice']); + return; + } + + Taxonomy::register(); + PostType::register(); + FieldGroup::register(); + } +``` + +- [ ] **Step 16.2: Run — expect all tests pass** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit +``` + +Expected: OK (8 tests, ≥35 assertions). All four test files green. + +- [ ] **Step 16.3: Commit** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +git add tests/PluginBootTest.php includes/class-plugin.php +git commit -m "feat(wpmc S2): Plugin::boot() wires Taxonomy + PostType + FieldGroup" +``` + +--- + +## Task 17: Update README roadmap status + +**Files:** +- Modify: `README.md` lines 22-29 (roadmap table) + +- [ ] **Step 17.1: Mark S2 as done in the roadmap** + +In `README.md`, change the S2 row: + +``` +| S2 | Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` | cp-q7g | open | +``` + +to: + +``` +| S2 | Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` | cp-q7g | done | +``` + +And update the S1 status row from `in progress` to `done`: + +``` +| S1 | Скелет (header, autoload, bootstrap) | cp-8ci | in progress | +``` + +to: + +``` +| S1 | Скелет (header, autoload, bootstrap) | cp-8ci | done | +``` + +- [ ] **Step 17.2: Commit** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +git add README.md +git commit -m "docs(wpmc): mark S1+S2 done in README roadmap" +``` + +--- + +## Task 18: Final verification + +**Files:** none + +- [ ] **Step 18.1: Full PHPUnit run** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +vendor/bin/phpunit +``` + +Expected: OK (8 tests, ≥35 assertions), exit code 0, no risky / no warnings. + +- [ ] **Step 18.2: PHP syntax lint on every new file** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +for f in includes/class-taxonomy.php includes/class-post-type.php includes/class-field-group.php includes/class-plugin.php tests/TestCase.php tests/TaxonomyTest.php tests/PostTypeTest.php tests/FieldGroupTest.php tests/PluginBootTest.php tests/bootstrap.php; do + php -l "$f" +done +``` + +Expected: `No syntax errors detected` for every file. + +- [ ] **Step 18.3: Git log review** + +```bash +cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city" +git log --oneline +``` + +Expected: chain of commits from `chore: init wp-multi-city …` through `docs(wpmc): mark S1+S2 done …`, each commit doing one logical thing. + +- [ ] **Step 18.4: Close beads task** + +```bash +bd close cp-q7g --reason "S2 done: taxonomy town + CPT shortcode-town + ACF list_shortcode registered with unit tests (Brain Monkey, 8 tests green)" +``` + +Expected: cp-q7g closed; `bd ready` now lists cp-arq (S3 shortcode_dmr) as the next ready front under epic cp-cw4. + +--- + +## Acceptance map (spec → tasks) + +| Spec requirement | Task | +|---|---| +| Brain Monkey + Mockery dev deps | 1 | +| PHPUnit config + bootstrap + TestCase | 1 | +| Taxonomy `town` with `hierarchical=false`, `show_in_rest=true`, `public=false`, `meta_box_cb=false` | 2-5 | +| CPT `shortcode-town` with `public=false`, `show_ui=true`, `supports=['title']`, `menu_position=8` | 6-9 | +| ACF group `group_wpmc_shortcode_rows` titled `WPMultiCity / Shortcode rows` | 13-14 | +| Repeater `list_shortcode` with sub-fields `town` (taxonomy, return_format=id) and `text` (textarea) | 13-14 | +| Group location = post_type==shortcode-town | 13-14 | +| Graceful no-op when ACF function missing | 12, 14 | +| `Plugin::boot()` wires up all three registrations | 15-16 | +| README roadmap status updated | 17 | +| Full green test run | 18 | +| Close cp-q7g | 18 |