22 KiB
Region / City / Product URLs 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: Serve regions at /{region}/, cities at /{city}/ and products at /{city}/{product}/. The client manages the region → city structure from a validated settings screen.
Architecture: A pure-PHP VC_Structure class holds the structure, validation, canonical-city choice and rewrite-rule generation, and is unit-tested without WordPress. Thin WordPress layers sit on top of it:
VC_Structure_Admin: settings screen, save, sync toviator_destinationtermsVC_Routing: rewrite rules, link filters, 301sVC_Menu: region items in the header menu
These layers are verified by php -l and a manual QA pass on a WordPress install.
Tech Stack: WordPress, PHP 8 (local CLI is PHP 8.4), plugin viator-collector, child theme astra-child-hoponhopoff. No Composer/PHPUnit: tests use a tiny runner at viator-collector/tests/run.php.
Spec: viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md
Global Constraints
- Preset regions, in order: UK
uk, Europeeurope, Asiaasia, Middle East and Africamiddle-east-and-africa, Americasamericas, Australasiaaustralasia. - Valid slug:
^[a-z0-9]+(?:-[a-z0-9]+)*$. - Reserved slugs:
wp-admin,wp-content,wp-includes,wp-json,feed,page,search,tag,category,author,tours,tour-destination,tour-tag,tour-category,blog,about,contact,faq,embed,attachment. - Term meta: region flag
_vc_region=1; city link_vc_viator_destination_id(already exists). - Post meta:
_vc_primary_destination(Viator destination ref string). - Options:
vc_structure(structure array),vc_flush_rewrite(flag). - All redirects are 301 and keep the query string.
- Match plugin style: static classes,
VC_prefix, tabs,vc_get_option,VC_Logger, text domainviator-collector. - Work on branch
feature/region-city-urls, notmain. tests/anddocs/never ship in the zip.
Structure array shape (used everywhere):
array(
array(
'name' => 'Europe',
'slug' => 'europe',
'term_id' => 12, // 0 until synced
'cities' => array(
array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam' ),
),
),
)
Review Focus
- A city slug equal to an unconfigured existing destination term's slug (e.g. two Viator "Victoria"s) must be a validation error, not a silent
wp_update_termfailure. Covered bytest_validate_taken_slug(Task 1). - A product whose Viator primary destination is not configured but which belongs to two configured cities must use the first city in settings order. Covered by
test_canonical_city_fallback_order(Task 1). - A product in no configured city keeps
/tours/{slug}/:canonical_city()returns''. Covered bytest_canonical_city_none(Task 1). - An admin-typed slug like
New Yorkoramsterdam!must be rejected, not silently changed. Covered bytest_validate_bad_slug(Task 1). - Filter and sort query strings (
?dest=…&sort=…&paged=2) must survive every 301. Covered by manual check M6 (Task 7).
Task 1: VC_Structure core and test runner
Files:
- Create:
viator-collector/includes/class-vc-structure.php - Create:
viator-collector/tests/run.php,viator-collector/tests/test-structure.php - Modify:
build-zip.php(add'tests'to$excluded_dirs)
Interfaces (Produces): class VC_Structure, all public static:
-
const OPTION = 'vc_structure'; -
default_regions(): array: the six presets,term_id0, no cities. -
get(): array:get_option( self::OPTION, self::default_regions() ), cached in a static per request. Untested; WordPress only. -
normalize( array $raw ): array- Shape POST data into the structure shape: trim names,
trim(strtolower())slugs, castterm_idto int andidto a digit string, drop city rows with an emptyid, reindex. - An empty region slug becomes
slugify( name ). An empty city slug becomesslugify( city name ).
- Shape POST data into the structure shape: trim names,
-
slugify( string $s ): string: lowercase, runs of non[a-z0-9]become-, trim-. -
reserved_slugs(): array: the list in Global Constraints. -
validate( array $structure, array $destination_map, array $taken ): array$destination_mapis Viatorid => name.$takenisslug => human labelfor slugs owned by something outside the structure.- Returns a list of error strings, empty when valid. Messages are exact:
Region %d has no name.(1-based)City ID %s is not a Viator destination.%s is listed more than once.(city name)Slug "%s" is invalid: use only a-z, 0-9 and -.Slug "%s" is reserved.Slug "%s" is used more than once.Slug "%s" is already used by %s.
-
cities( array $structure ): array: orderedviator_id => city + array( 'region' => region slug ). -
canonical_city( array $structure, array $dest_ids, string $primary_id ): string- Returns the city slug for
$primary_idif it is configured. - Otherwise returns the slug of the first configured city (in
cities()order) that appears in$dest_ids. - Otherwise returns
''.
- Returns the city slug for
-
primary_destination_id( array $destinations ): string:ref(ordestinationId) of the entry with truthyprimary, else''. -
rewrite_rules( array $structure ): array, regex => query, in this order:- each region:
'^europe/?$' => 'index.php?viator_destination=europe' - each city:
'^amsterdam/?$' => 'index.php?viator_destination=amsterdam'and'^amsterdam/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]'
- each region:
-
Step 1: Write the runner.
tests/run.phpdefinesABSPATH, requiresincludes/class-vc-structure.phpand everytests/test-*.php, then calls each function whose name starts withtest_. It providesvc_assert_same( $expected, $actual, string $msg ), which throws on mismatch, andvc_assert_contains( string $needle, array $errors ). It printsN passed, M failedand exits 1 on any failure. -
Step 2: Write the failing tests in
tests/test-structure.php. Use the fixture$s= Europe(amsterdamid 737,parisid 479) + Americas(bostonid 678) and$map = array('737'=>'Amsterdam','479'=>'Paris','678'=>'Boston','22'=>'Victoria').
test_default_regions // 6 regions; slugs exactly the Global Constraints list, in order
test_normalize_fills_slugs // region name 'Middle East and Africa', slug '' -> 'middle-east-and-africa'; city row with id '' dropped
test_validate_ok // validate($s,$map,[]) === []
test_validate_unknown_city // city id '999' -> contains 'City ID 999 is not a Viator destination.'
test_validate_duplicate_city // 737 in Europe and Americas -> contains 'Amsterdam is listed more than once.'
test_validate_duplicate_slug // region slug 'paris' + city 'paris' -> contains 'Slug "paris" is used more than once.'
test_validate_bad_slug // 'New York' and 'amsterdam!' -> both 'Slug "…" is invalid: use only a-z, 0-9 and -.'
test_validate_reserved // city slug 'contact' -> contains 'Slug "contact" is reserved.'
test_validate_taken_slug // $taken=['victoria'=>'destination "Victoria"'], city slug 'victoria' -> contains 'Slug "victoria" is already used by destination "Victoria".'
test_validate_empty_region // region name '' -> contains 'Region 1 has no name.'
test_canonical_city_primary // canonical_city($s,['479','737'],'479') === 'paris'
test_canonical_city_fallback_order // canonical_city($s,['678','479','737'],'5555') === 'amsterdam'
test_canonical_city_none // canonical_city($s,['5555'],'') === ''
test_primary_destination_id // [['ref'=>'1'],['ref'=>'2','primary'=>true]] -> '2'; [] -> ''
test_rewrite_rules // exact array: europe, americas, amsterdam, amsterdam product, paris, paris product, boston, boston product (region rules first, then cities)
test_rewrite_rules_empty_region // region with no cities still yields '^europe/?$'
-
Step 3: Run and confirm the failure. Run
php tests/run.phpfromviator-collector/. Expected: a fatal error saying classVC_Structurewas not found. -
Step 4: Implement
VC_Structurewith the interface above. Onlyget()may call WordPress functions. -
Step 5: Run and confirm the pass. Run
php tests/run.php. Expected:16 passed, 0 failed. -
Step 6: Exclude tests from the zip. Add
'tests'to$excluded_dirsinbuild-zip.php. Then runphp build-zip.php viator-collector "$TMP/vc.zip"and confirm notests/entries are in the zip. -
Step 7: Commit. Commit message:
feat(structure): add region/city structure model with validation.
Task 2: Importer stores the primary destination; crawl follows the structure
Files:
- Modify:
viator-collector/includes/class-vc-importer.php(the$metaarray insave_tour_meta, ~line 370) - Modify:
viator-collector/includes/class-vc-cron.php(start_run, ~lines 159-166) - Modify:
viator-collector/includes/class-vc-settings.php(description of the crawl destinations field) - Modify:
viator-collector/includes/class-vc-plugin.php(loadclass-vc-structure.phpbefore the importer)
Interfaces (Consumes): VC_Structure::primary_destination_id(), VC_Structure::cities(), VC_Structure::get().
-
Step 1: Store the primary destination. Add
'_vc_primary_destination' => VC_Structure::primary_destination_id( (array) vc_array_get( $product, 'destinations', array() ) )to the$metaarray. -
Step 2: Build the crawl queue from the structure. In
start_run:- If
array_keys( VC_Structure::cities( VC_Structure::get() ) )is non-empty, the queue is exactly that list. - Otherwise keep the existing allowlist/full-catalogue logic.
- Log
Sync run started from structure — %d cities queued.in the structure case.
- If
-
Step 3: Relabel the old allowlist. Append
Used only when no cities are set on the Viator Structure screen.to the crawl destinations field's description. -
Step 4: Lint. Run
php -lon each changed file. Expected:No syntax errors detectedfor each. Then runphp tests/run.php, which should still pass. -
Step 5: Commit. Commit message:
feat(import): store primary destination; crawl configured cities.
Task 3: Structure settings screen, validated save, and term sync
Files:
- Create:
viator-collector/includes/class-vc-structure-admin.php - Create:
viator-collector/assets/js/vc-structure-admin.js - Modify:
viator-collector/includes/class-vc-plugin.php(load the file and callVC_Structure_Admin::init())
Interfaces:
-
Consumes: all of
VC_Structure; existing AJAX actionvc_search_destinations(noncevc_search_destinations, POSTq, returns{results:[{id,name}]});VC_Taxonomies::get_or_create_destination_by_id( $id, $name );VC_API::get_destination_map(). -
Produces:
VC_Structure_Admin::init()VC_Structure_Admin::taken_slugs( array $structure ): array, slug => labelVC_Structure_Admin::sync_terms( array $old, array $new ): array, returns$newwith regionterm_ids filled- Sets option
vc_flush_rewrite= 1 after a successful save
-
Step 1: Add the screen. Register a submenu under Settings with
add_options_page: titleViator Structure, slugviator-collector-structure, capabilitymanage_options.- The page renders one block per region: name input, slug input, a hidden
term_id, and up/down/remove buttons. - Each region block holds its city rows. A city row shows the name read-only, an editable slug, and a hidden
idandname, with up/down/remove buttons. - Each region has a city search box that calls
vc_search_destinations. Results whose id is already used anywhere in the form are shown disabled. - An "Add region" button sits at the end.
- Field names:
vc_structure[i][name|slug|term_id]andvc_structure[i][cities][j][id|name|slug]. The JS renumbers indexes before submit. - The form posts to
admin-post.php, actionvc_save_structure, noncevc_save_structure.
- The page renders one block per region: name input, slug input, a hidden
-
Step 2: Implement
taken_slugs( $structure ). It returns these asslug => label:- top-level Pages (
post_parent0), labelledpage "Title" - posts of every public post type in any status except trash and auto-draft, labelled
post "Title" viator_destinationterms that are neither a structure region (term_id) nor a structure city (matched through_vc_viator_destination_id), labelleddestination "Name"
- top-level Pages (
-
Step 3: Implement the save handler.
- Check the capability and nonce.
- Run
normalize( wp_unslash( $_POST['vc_structure'] ?? array() ) ). - Load the destination map. If that returns a
WP_Error, treat it as the single errorCould not load Viator destinations; nothing was saved. - Run
validate( $s, $map, self::taken_slugs( $s ) ). - On errors, store
array( 'errors' => …, 'structure' => $s )in transientvc_structure_draft_{user_id}(10 minutes) and redirect back. The screen re-renders that draft with a red notice listing every error. Nothing is saved. - On success:
$s = self::sync_terms( VC_Structure::get(), $s ), thenupdate_option( VC_Structure::OPTION, $s ), thenupdate_option( 'vc_flush_rewrite', 1 ), then redirect with the noticeStructure saved.
-
Step 4: Implement
sync_terms( $old, $new ).- Region: update the term by
term_idif it exists (name, slug, parent 0). Otherwisewp_insert_term( name, 'viator_destination', array( 'slug' => slug ) ). In both cases set_vc_region= 1 and store theterm_idback. - City:
$tid = VC_Taxonomies::get_or_create_destination_by_id( id, name ), thenwp_update_term( $tid, 'viator_destination', array( 'slug' => slug, 'parent' => region term_id ) ). - Removed regions (old
term_ids that are no longer present):wp_delete_term. - Removed cities (old ids that are no longer present): set
parentto 0. - Log every
WP_ErrorwithVC_Logger::error.
- Region: update the term by
-
Step 5: Add the page-slug clash notice. On
admin_notices, on the page edit screen, if the current page'spost_nameis a structure region or city slug, show the warningThis page's URL is also used by the Viator region/city "%s"; the Viator page will take priority. -
Step 6: Lint. Run
php -l includes/class-vc-structure-admin.phpandnode --check assets/js/vc-structure-admin.js. Expected: no errors. -
Step 7: Commit. Commit message:
feat(structure): admin screen with validated save and term sync.
Task 4: Routing — rewrite rules, links, redirects
Files:
- Create:
viator-collector/includes/class-vc-routing.php - Modify:
viator-collector/includes/class-vc-plugin.php(load the file and callVC_Routing::init())
Interfaces:
-
Consumes:
VC_Structure::get(),::rewrite_rules(),::cities(),::canonical_city(); optionvc_flush_rewrite. -
Produces:
VC_Routing::init(),VC_Routing::is_configured_term( WP_Term $term ): bool,VC_Routing::product_city( int $post_id ): string -
Step 1: Register the rules. On
initat priority 20, calladd_rewrite_rule( $regex, $query, 'top' )for eachrewrite_rules( get() )entry. Then, ifget_option( 'vc_flush_rewrite' )is set, delete it and callflush_rewrite_rules( false ). -
Step 2: Implement
is_configured_term. It is true when the term is inviator_destinationand either has_vc_region= 1 or its_vc_viator_destination_idis a key ofcities(). -
Step 3: Implement
product_city. Build$dest_idsfrom theref/destinationIdvalues in the post's_vc_destinationsJSON meta. Returncanonical_city( get(), $dest_ids, (string) _vc_primary_destination ). Cache the result perpost_idin a static. -
Step 4: Add the link filters.
term_link: for a configured term, returnhome_url( '/' . $term->slug . '/' ).post_type_link: for aviator_tourwith a non-emptypost_nameand a non-emptyproduct_city, returnhome_url( "/{city}/{post_name}/" ).- Everything else is returned unchanged.
-
Step 5: Add the 301s. On
template_redirect, foris_singular( 'viator_tour' )(published, not a preview) and for configuredis_tax( 'viator_destination' ):- Compute the canonical URL (
get_permalink()/get_term_link()). - Compare
untrailingslashitpaths of the canonical URL andREQUEST_URIwith the query string removed. - If they differ,
wp_safe_redirect( canonical + '?' + original query string if any, 301 )and exit.
- Compute the canonical URL (
-
Step 6: Add the term canonical tag. On
wp_head, for a configured term archive, print<link rel="canonical" href="…">. Skip this whenWPSEO_VERSIONorRANK_MATH_VERSIONis defined. Singular products already get core's canonical fromget_permalink(). -
Step 7: Lint and commit. Run
php -l includes/class-vc-routing.php. Expected: no errors. Commit message:feat(routing): region/city/product URLs with 301s from old paths.
Task 5: Region landing page
Files:
- Create:
viator-collector/templates/region.php - Modify:
viator-collector/includes/class-vc-display.php(taxonomy_template, ~line 534) - Modify:
viator-collector/templates/listing.php(new optional arg$vc_after_hero)
Interfaces (Consumes): VC_Routing::is_configured_term(); existing vc_get_template(), card-destination.php (arg term), listing.php.
-
Step 1: Route region terms. In
taxonomy_template, when the queried term has_vc_region= 1, returnget_stylesheet_directory() . '/viator-collector/region.php'if it exists, elseVC_PLUGIN_DIR . 'templates/region.php'. -
Step 2: Add
$vc_after_herotolisting.php. It is an optional HTML string, default'', echoed immediately after the listing hero section closes. -
Step 3: Write
templates/region.php.- Copy the structure of
taxonomy-viator.php. - Build
$vc_after_heroas<section class="vc-section vc-section--destinations">. It holds the headingCities in %sand avc-destination-gridofcard-destination.phpcards. - The cards are the child terms from
get_terms( array( 'taxonomy' => 'viator_destination', 'parent' => term_id, 'hide_empty' => false ) ), ordered as in the structure (the order ofcities()). - The listing's main query already includes child terms, so it shows every tour in the region.
- Copy the structure of
-
Step 4: Lint and commit. Run
php -lon the three files. Commit message:feat(display): region landing page with city grid.
Task 6: Regions in the header menu
Files:
- Create:
viator-collector/includes/class-vc-menu.php - Modify:
viator-collector/includes/class-vc-plugin.php(load the file and callVC_Menu::init()) - Modify:
astra-child-hoponhopoff/inc/nav-walker.php(hoho_nav_fallback)
Interfaces:
-
Consumes:
VC_Structure::get(); region and city terms (term_id, links viaget_term_link). -
Produces:
VC_Menu::items(): arrayof menu-item objects. -
Step 1: Implement
VC_Menu::items(). BuildstdClassitems with these fields:ID,db_id,menu_item_parent(string),title,url,classes(array),type'custom',object'custom',object_id,target'',xfn'',attr_title'',description'',currentfalse.- Use negative unique
ID/db_id:-1000 - n. - Each region gets a top-level item.
- A region with cities gets these children, in order:
All of %s(the region URL, because the theme walker turns a parent with children into a toggle button with no link), then one item per city. - Regions whose term no longer exists are skipped.
- Use negative unique
-
Step 2: Append to the header menu. Add the filter
wp_nav_menu_objects($items,$args). When$args->theme_location === 'hoho_primary', returnarray_merge( $items, self::items() ). Setmenu_ordersequentially after the existing items. -
Step 3: Update the theme fallback. In
hoho_nav_fallback, after the Tours link and whenclass_exists( 'VC_Menu' ), echowalk_nav_menu_tree( VC_Menu::items(), 2, (object) array( 'walker' => new Hoho_Nav_Walker() ) )inside the same<ul>. -
Step 4: Lint and commit. Run
php -lon both PHP files. Commit message:feat(menu): list regions and cities in the header menu.
Task 7: Version bump, build, and manual QA
Files:
-
Modify:
viator-collector/viator-collector.php(Version header andVC_VERSION→1.9.0) -
Step 1: Bump the version, run the tests and build the zips.
- Bump the version to 1.9.0.
- Run
php tests/run.php. Expected: all tests pass. - From the repo root, run
php build-zip.php viator-collector viator-collector.zip.
-
Step 2: Manual QA on a WordPress install with the client's live data copied over. Record pass or fail for each check:
- M1. Save the preset regions and add
BostonandNew York Cityunder Americas. The menu shows the six regions, and Americas drops down toAll of Americas, Boston and New York City. - M2.
/americas/shows the city grid and all of their tours./boston/lists Boston tours. - M3.
/tour-destination/boston/gives a 301 to/boston/. Do the same check for all 15 of the client's URLs once those cities are added. - M4. A Boston tour's card links to
/boston/{slug}/./tours/{slug}/and/new-york-city/{slug}/both give a 301 to it. - M5. Saving each of these shows an error, and the previous structure is unchanged:
- a city twice
- slug
contact - slug
New York - a city slug equal to an existing page's slug
- M6.
/boston/?sort=price_asc&paged=2keeps its query./tour-destination/boston/?sort=price_ascgives a 301 to/boston/?sort=price_asc. - M7. Run a manual sync. The log reads
cities queuedwith the configured count. - M8.
/contact/and other pages still load.
- M1. Save the preset regions and add
-
Step 3: Commit. Commit message:
chore: bump viator-collector to 1.9.0.