Files
hoponhopofftickets/viator-collector/docs/superpowers/plans/2026-10-29-region-city-urls.md
2026-10-29 15:18:26 +03:30

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 to viator_destination terms
  • VC_Routing: rewrite rules, link filters, 301s
  • VC_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, Europe europe, Asia asia, Middle East and Africa middle-east-and-africa, Americas americas, Australasia australasia.
  • 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 domain viator-collector.
  • Work on branch feature/region-city-urls, not main.
  • tests/ and docs/ 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

  1. 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_term failure. Covered by test_validate_taken_slug (Task 1).
  2. 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).
  3. A product in no configured city keeps /tours/{slug}/: canonical_city() returns ''. Covered by test_canonical_city_none (Task 1).
  4. An admin-typed slug like New York or amsterdam! must be rejected, not silently changed. Covered by test_validate_bad_slug (Task 1).
  5. 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_id 0, 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, cast term_id to int and id to a digit string, drop city rows with an empty id, reindex.
    • An empty region slug becomes slugify( name ). An empty city slug becomes slugify( city name ).
  • 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_map is Viator id => name. $taken is slug => human label for 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: ordered viator_id => city + array( 'region' => region slug ).

  • canonical_city( array $structure, array $dest_ids, string $primary_id ): string

    • Returns the city slug for $primary_id if it is configured.
    • Otherwise returns the slug of the first configured city (in cities() order) that appears in $dest_ids.
    • Otherwise returns ''.
  • primary_destination_id( array $destinations ): string: ref (or destinationId) of the entry with truthy primary, 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]'
  • Step 1: Write the runner. tests/run.php defines ABSPATH, requires includes/class-vc-structure.php and every tests/test-*.php, then calls each function whose name starts with test_. It provides vc_assert_same( $expected, $actual, string $msg ), which throws on mismatch, and vc_assert_contains( string $needle, array $errors ). It prints N passed, M failed and exits 1 on any failure.

  • Step 2: Write the failing tests in tests/test-structure.php. Use the fixture $s = Europe(amsterdam id 737, paris id 479) + Americas(boston id 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.php from viator-collector/. Expected: a fatal error saying class VC_Structure was not found.

  • Step 4: Implement VC_Structure with the interface above. Only get() 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_dirs in build-zip.php. Then run php build-zip.php viator-collector "$TMP/vc.zip" and confirm no tests/ 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 $meta array in save_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 (load class-vc-structure.php before 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 $meta array.

  • 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.
  • 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 -l on each changed file. Expected: No syntax errors detected for each. Then run php 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 call VC_Structure_Admin::init())

Interfaces:

  • Consumes: all of VC_Structure; existing AJAX action vc_search_destinations (nonce vc_search_destinations, POST q, 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 => label
    • VC_Structure_Admin::sync_terms( array $old, array $new ): array, returns $new with region term_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: title Viator Structure, slug viator-collector-structure, capability manage_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 id and name, 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] and vc_structure[i][cities][j][id|name|slug]. The JS renumbers indexes before submit.
    • The form posts to admin-post.php, action vc_save_structure, nonce vc_save_structure.
  • Step 2: Implement taken_slugs( $structure ). It returns these as slug => label:

    • top-level Pages (post_parent 0), labelled page "Title"
    • posts of every public post type in any status except trash and auto-draft, labelled post "Title"
    • viator_destination terms that are neither a structure region (term_id) nor a structure city (matched through _vc_viator_destination_id), labelled destination "Name"
  • Step 3: Implement the save handler.

    1. Check the capability and nonce.
    2. Run normalize( wp_unslash( $_POST['vc_structure'] ?? array() ) ).
    3. Load the destination map. If that returns a WP_Error, treat it as the single error Could not load Viator destinations; nothing was saved.
    4. Run validate( $s, $map, self::taken_slugs( $s ) ).
    5. On errors, store array( 'errors' => …, 'structure' => $s ) in transient vc_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.
    6. On success: $s = self::sync_terms( VC_Structure::get(), $s ), then update_option( VC_Structure::OPTION, $s ), then update_option( 'vc_flush_rewrite', 1 ), then redirect with the notice Structure saved.
  • Step 4: Implement sync_terms( $old, $new ).

    • Region: update the term by term_id if it exists (name, slug, parent 0). Otherwise wp_insert_term( name, 'viator_destination', array( 'slug' => slug ) ). In both cases set _vc_region = 1 and store the term_id back.
    • City: $tid = VC_Taxonomies::get_or_create_destination_by_id( id, name ), then wp_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 parent to 0.
    • Log every WP_Error with VC_Logger::error.
  • Step 5: Add the page-slug clash notice. On admin_notices, on the page edit screen, if the current page's post_name is a structure region or city slug, show the warning This 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.php and node --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.

Files:

  • Create: viator-collector/includes/class-vc-routing.php
  • Modify: viator-collector/includes/class-vc-plugin.php (load the file and call VC_Routing::init())

Interfaces:

  • Consumes: VC_Structure::get(), ::rewrite_rules(), ::cities(), ::canonical_city(); option vc_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 init at priority 20, call add_rewrite_rule( $regex, $query, 'top' ) for each rewrite_rules( get() ) entry. Then, if get_option( 'vc_flush_rewrite' ) is set, delete it and call flush_rewrite_rules( false ).

  • Step 2: Implement is_configured_term. It is true when the term is in viator_destination and either has _vc_region = 1 or its _vc_viator_destination_id is a key of cities().

  • Step 3: Implement product_city. Build $dest_ids from the ref / destinationId values in the post's _vc_destinations JSON meta. Return canonical_city( get(), $dest_ids, (string) _vc_primary_destination ). Cache the result per post_id in a static.

  • Step 4: Add the link filters.

    • term_link: for a configured term, return home_url( '/' . $term->slug . '/' ).
    • post_type_link: for a viator_tour with a non-empty post_name and a non-empty product_city, return home_url( "/{city}/{post_name}/" ).
    • Everything else is returned unchanged.
  • Step 5: Add the 301s. On template_redirect, for is_singular( 'viator_tour' ) (published, not a preview) and for configured is_tax( 'viator_destination' ):

    • Compute the canonical URL (get_permalink() / get_term_link()).
    • Compare untrailingslashit paths of the canonical URL and REQUEST_URI with the query string removed.
    • If they differ, wp_safe_redirect( canonical + '?' + original query string if any, 301 ) and exit.
  • Step 6: Add the term canonical tag. On wp_head, for a configured term archive, print <link rel="canonical" href="…">. Skip this when WPSEO_VERSION or RANK_MATH_VERSION is defined. Singular products already get core's canonical from get_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, return get_stylesheet_directory() . '/viator-collector/region.php' if it exists, else VC_PLUGIN_DIR . 'templates/region.php'.

  • Step 2: Add $vc_after_hero to listing.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_hero as <section class="vc-section vc-section--destinations">. It holds the heading Cities in %s and a vc-destination-grid of card-destination.php cards.
    • 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 of cities()).
    • The listing's main query already includes child terms, so it shows every tour in the region.
  • Step 4: Lint and commit. Run php -l on 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 call VC_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 via get_term_link).

  • Produces: VC_Menu::items(): array of menu-item objects.

  • Step 1: Implement VC_Menu::items(). Build stdClass items 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 '', current false.

    • 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.
  • Step 2: Append to the header menu. Add the filter wp_nav_menu_objects ($items, $args). When $args->theme_location === 'hoho_primary', return array_merge( $items, self::items() ). Set menu_order sequentially after the existing items.

  • Step 3: Update the theme fallback. In hoho_nav_fallback, after the Tours link and when class_exists( 'VC_Menu' ), echo walk_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 -l on 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 and VC_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 Boston and New York City under Americas. The menu shows the six regions, and Americas drops down to All 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=2 keeps its query. /tour-destination/boston/?sort=price_asc gives a 301 to /boston/?sort=price_asc.
    • M7. Run a manual sync. The log reads cities queued with the configured count.
    • M8. /contact/ and other pages still load.
  • Step 3: Commit. Commit message: chore: bump viator-collector to 1.9.0.