feat(structure): add region/city structure model with validation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 15:19:49 +03:30
parent cfa23b6384
commit 614ea98825
4 changed files with 403 additions and 2 deletions

View File

@@ -9,7 +9,7 @@
* was produced ships to the client.
*
* Exclusion rule (keep in sync with the "no docs/AI files in zips" preference):
* - any path segment: .git, .claude, .github, docs, build, node_modules
* - any path segment: .git, .claude, .github, docs, build, node_modules, tests
* - any *.md file
* - dotfiles at any level except .htaccess
*/
@@ -22,7 +22,7 @@ if ( '' === $src || '' === $out || ! is_dir( $src ) ) {
exit( 1 );
}
$excluded_dirs = array( '.git', '.claude', '.github', 'docs', 'build', 'node_modules' );
$excluded_dirs = array( '.git', '.claude', '.github', 'docs', 'build', 'node_modules', 'tests' );
function is_excluded( $rel, array $excluded_dirs ) {
$parts = explode( '/', $rel );

View File

@@ -0,0 +1,208 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* The client-managed region → city structure behind the /{region}/,
* /{city}/ and /{city}/{product}/ URLs.
*
* Everything here except get() is plain PHP so it can be unit-tested
* without WordPress (see tests/run.php).
*
* Shape: list of regions, each
* array( 'name', 'slug', 'term_id', 'cities' => list of array( 'id', 'name', 'slug' ) )
* where a city's 'id' is its Viator destination id.
*/
class VC_Structure {
const OPTION = 'vc_structure';
private static $cache = null;
public static function default_regions() {
$regions = array(
'uk' => 'UK',
'europe' => 'Europe',
'asia' => 'Asia',
'middle-east-and-africa' => 'Middle East and Africa',
'americas' => 'Americas',
'australasia' => 'Australasia',
);
$out = array();
foreach ( $regions as $slug => $name ) {
$out[] = array(
'name' => $name,
'slug' => $slug,
'term_id' => 0,
'cities' => array(),
);
}
return $out;
}
public static function get() {
if ( null === self::$cache ) {
$value = get_option( self::OPTION, self::default_regions() );
self::$cache = is_array( $value ) ? $value : self::default_regions();
}
return self::$cache;
}
/**
* Drop the per-request cache (after the option is saved).
*/
public static function flush_cache() {
self::$cache = null;
}
public static function slugify( $s ) {
$s = strtolower( (string) $s );
$s = preg_replace( '/[^a-z0-9]+/', '-', $s );
return trim( $s, '-' );
}
/**
* Shape submitted form data into the structure. Admin-typed slugs are only
* trimmed/lowercased here — validate() rejects anything still invalid
* rather than silently rewriting it.
*/
public static function normalize( array $raw ) {
$out = array();
foreach ( $raw as $region ) {
if ( ! is_array( $region ) ) {
continue;
}
$name = trim( (string) ( $region['name'] ?? '' ) );
$slug = strtolower( trim( (string) ( $region['slug'] ?? '' ) ) );
$cities = array();
foreach ( (array) ( $region['cities'] ?? array() ) as $city ) {
$id = trim( (string) ( is_array( $city ) ? ( $city['id'] ?? '' ) : '' ) );
if ( '' === $id ) {
continue;
}
$city_name = trim( (string) ( $city['name'] ?? '' ) );
$city_slug = strtolower( trim( (string) ( $city['slug'] ?? '' ) ) );
$cities[] = array(
'id' => $id,
'name' => $city_name,
'slug' => '' !== $city_slug ? $city_slug : self::slugify( $city_name ),
);
}
$out[] = array(
'name' => $name,
'slug' => '' !== $slug ? $slug : self::slugify( $name ),
'term_id' => (int) ( $region['term_id'] ?? 0 ),
'cities' => $cities,
);
}
return $out;
}
public static function reserved_slugs() {
return array( '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' );
}
/**
* @param array $destination_map Viator destination id => name.
* @param array $taken slug => label, for slugs owned outside the structure.
* @return string[] Errors; empty when valid.
*/
public static function validate( array $structure, array $destination_map, array $taken ) {
$errors = array();
$slugs = array();
$city_ids = array();
foreach ( $structure as $i => $region ) {
if ( '' === trim( (string) $region['name'] ) ) {
$errors[] = sprintf( 'Region %d has no name.', $i + 1 );
}
$slugs[] = (string) $region['slug'];
foreach ( $region['cities'] as $city ) {
$id = (string) $city['id'];
if ( ! isset( $destination_map[ $id ] ) ) {
$errors[] = sprintf( 'City ID %s is not a Viator destination.', $id );
}
if ( isset( $city_ids[ $id ] ) ) {
$errors[] = sprintf( '%s is listed more than once.', '' !== $city['name'] ? $city['name'] : $id );
}
$city_ids[ $id ] = true;
$slugs[] = (string) $city['slug'];
}
}
$reserved = self::reserved_slugs();
foreach ( array_count_values( $slugs ) as $slug => $count ) {
$slug = (string) $slug;
if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
$errors[] = sprintf( 'Slug "%s" is invalid: use only a-z, 0-9 and -.', $slug );
continue;
}
if ( in_array( $slug, $reserved, true ) ) {
$errors[] = sprintf( 'Slug "%s" is reserved.', $slug );
}
if ( $count > 1 ) {
$errors[] = sprintf( 'Slug "%s" is used more than once.', $slug );
}
if ( isset( $taken[ $slug ] ) ) {
$errors[] = sprintf( 'Slug "%s" is already used by %s.', $slug, $taken[ $slug ] );
}
}
return array_values( array_unique( $errors ) );
}
/**
* Configured cities in settings order: viator id => city + 'region' slug.
*/
public static function cities( array $structure ) {
$out = array();
foreach ( $structure as $region ) {
foreach ( $region['cities'] as $city ) {
$out[ (string) $city['id'] ] = $city + array( 'region' => $region['slug'] );
}
}
return $out;
}
/**
* The city a product's URL lives under: its Viator primary destination
* when configured, else the first configured city it belongs to, else ''.
*/
public static function canonical_city( array $structure, array $dest_ids, $primary_id ) {
$cities = self::cities( $structure );
if ( '' !== (string) $primary_id && isset( $cities[ (string) $primary_id ] ) ) {
return $cities[ (string) $primary_id ]['slug'];
}
$dest_ids = array_map( 'strval', $dest_ids );
foreach ( $cities as $id => $city ) {
if ( in_array( (string) $id, $dest_ids, true ) ) {
return $city['slug'];
}
}
return '';
}
public static function primary_destination_id( array $destinations ) {
foreach ( $destinations as $destination ) {
if ( is_array( $destination ) && ! empty( $destination['primary'] ) ) {
$ref = $destination['ref'] ?? ( $destination['destinationId'] ?? '' );
return (string) $ref;
}
}
return '';
}
/**
* Explicit per-slug rules (no catch-alls), regions first, then cities.
*/
public static function rewrite_rules( array $structure ) {
$rules = array();
foreach ( $structure as $region ) {
$rules[ '^' . $region['slug'] . '/?$' ] = 'index.php?viator_destination=' . $region['slug'];
}
foreach ( self::cities( $structure ) as $city ) {
$rules[ '^' . $city['slug'] . '/?$' ] = 'index.php?viator_destination=' . $city['slug'];
$rules[ '^' . $city['slug'] . '/([^/]+)/?$' ] = 'index.php?viator_tour=$matches[1]';
}
return $rules;
}
}

View File

@@ -0,0 +1,44 @@
<?php
/**
* Minimal test runner for the WordPress-free parts of the plugin.
*
* php tests/run.php
*/
define( 'ABSPATH', __DIR__ . '/' );
require_once dirname( __DIR__ ) . '/includes/class-vc-structure.php';
function vc_assert_same( $expected, $actual, $msg = '' ) {
if ( $expected !== $actual ) {
throw new Exception( $msg . "\n expected: " . var_export( $expected, true ) . "\n actual: " . var_export( $actual, true ) );
}
}
function vc_assert_contains( $needle, array $errors ) {
if ( ! in_array( $needle, $errors, true ) ) {
throw new Exception( 'Missing error: ' . $needle . "\n got: " . var_export( $errors, true ) );
}
}
foreach ( glob( __DIR__ . '/test-*.php' ) as $file ) {
require_once $file;
}
$passed = 0;
$failed = 0;
foreach ( get_defined_functions()['user'] as $fn ) {
if ( 0 !== strpos( $fn, 'test_' ) ) {
continue;
}
try {
$fn();
$passed++;
} catch ( Throwable $e ) {
$failed++;
echo 'FAIL ' . $fn . ': ' . $e->getMessage() . "\n";
}
}
echo $passed . ' passed, ' . $failed . " failed\n";
exit( $failed ? 1 : 0 );

View File

@@ -0,0 +1,149 @@
<?php
/**
* VC_Structure unit tests.
*/
function vc_fixture_structure() {
return array(
array(
'name' => 'Europe',
'slug' => 'europe',
'term_id' => 0,
'cities' => array(
array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam' ),
array( 'id' => '479', 'name' => 'Paris', 'slug' => 'paris' ),
),
),
array(
'name' => 'Americas',
'slug' => 'americas',
'term_id' => 0,
'cities' => array(
array( 'id' => '678', 'name' => 'Boston', 'slug' => 'boston' ),
),
),
);
}
function vc_fixture_map() {
return array( '737' => 'Amsterdam', '479' => 'Paris', '678' => 'Boston', '22' => 'Victoria' );
}
function test_default_regions() {
$regions = VC_Structure::default_regions();
vc_assert_same(
array( 'uk', 'europe', 'asia', 'middle-east-and-africa', 'americas', 'australasia' ),
array_column( $regions, 'slug' )
);
vc_assert_same( 'Middle East and Africa', $regions[3]['name'] );
vc_assert_same( array(), $regions[0]['cities'] );
vc_assert_same( 0, $regions[0]['term_id'] );
}
function test_normalize_fills_slugs() {
$s = VC_Structure::normalize(
array(
array(
'name' => ' Middle East and Africa ',
'slug' => '',
'term_id' => '7',
'cities' => array(
array( 'id' => '', 'name' => 'Ghost', 'slug' => 'ghost' ),
array( 'id' => '828', 'name' => 'Dubai', 'slug' => '' ),
),
),
)
);
vc_assert_same( 'Middle East and Africa', $s[0]['name'] );
vc_assert_same( 'middle-east-and-africa', $s[0]['slug'] );
vc_assert_same( 7, $s[0]['term_id'] );
vc_assert_same( array( array( 'id' => '828', 'name' => 'Dubai', 'slug' => 'dubai' ) ), $s[0]['cities'] );
}
function test_validate_ok() {
vc_assert_same( array(), VC_Structure::validate( vc_fixture_structure(), vc_fixture_map(), array() ) );
}
function test_validate_unknown_city() {
$s = vc_fixture_structure();
$s[1]['cities'][0]['id'] = '999';
vc_assert_contains( 'City ID 999 is not a Viator destination.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
}
function test_validate_duplicate_city() {
$s = vc_fixture_structure();
$s[1]['cities'][] = array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam-2' );
vc_assert_contains( 'Amsterdam is listed more than once.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
}
function test_validate_duplicate_slug() {
$s = vc_fixture_structure();
$s[1]['slug'] = 'paris';
vc_assert_contains( 'Slug "paris" is used more than once.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
}
function test_validate_bad_slug() {
$s = vc_fixture_structure();
$s[0]['cities'][0]['slug'] = 'amsterdam!';
$s[1]['cities'][0]['slug'] = 'New York';
$errors = VC_Structure::validate( $s, vc_fixture_map(), array() );
vc_assert_contains( 'Slug "amsterdam!" is invalid: use only a-z, 0-9 and -.', $errors );
vc_assert_contains( 'Slug "New York" is invalid: use only a-z, 0-9 and -.', $errors );
}
function test_validate_reserved() {
$s = vc_fixture_structure();
$s[0]['cities'][0]['slug'] = 'contact';
vc_assert_contains( 'Slug "contact" is reserved.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
}
function test_validate_taken_slug() {
$s = vc_fixture_structure();
$s[1]['cities'][] = array( 'id' => '22', 'name' => 'Victoria', 'slug' => 'victoria' );
$errors = VC_Structure::validate( $s, vc_fixture_map(), array( 'victoria' => 'destination "Victoria"' ) );
vc_assert_contains( 'Slug "victoria" is already used by destination "Victoria".', $errors );
}
function test_validate_empty_region() {
$s = vc_fixture_structure();
$s[0]['name'] = '';
vc_assert_contains( 'Region 1 has no name.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
}
function test_canonical_city_primary() {
vc_assert_same( 'paris', VC_Structure::canonical_city( vc_fixture_structure(), array( '479', '737' ), '479' ) );
}
function test_canonical_city_fallback_order() {
vc_assert_same( 'amsterdam', VC_Structure::canonical_city( vc_fixture_structure(), array( '678', '479', '737' ), '5555' ) );
}
function test_canonical_city_none() {
vc_assert_same( '', VC_Structure::canonical_city( vc_fixture_structure(), array( '5555' ), '' ) );
}
function test_primary_destination_id() {
vc_assert_same( '2', VC_Structure::primary_destination_id( array( array( 'ref' => '1' ), array( 'ref' => '2', 'primary' => true ) ) ) );
vc_assert_same( '', VC_Structure::primary_destination_id( array() ) );
}
function test_rewrite_rules() {
vc_assert_same(
array(
'^europe/?$' => 'index.php?viator_destination=europe',
'^americas/?$' => 'index.php?viator_destination=americas',
'^amsterdam/?$' => 'index.php?viator_destination=amsterdam',
'^amsterdam/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
'^paris/?$' => 'index.php?viator_destination=paris',
'^paris/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
'^boston/?$' => 'index.php?viator_destination=boston',
'^boston/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
),
VC_Structure::rewrite_rules( vc_fixture_structure() )
);
}
function test_rewrite_rules_empty_region() {
$rules = VC_Structure::rewrite_rules( array( array( 'name' => 'Europe', 'slug' => 'europe', 'term_id' => 0, 'cities' => array() ) ) );
vc_assert_same( array( '^europe/?$' => 'index.php?viator_destination=europe' ), $rules );
}