feat(structure): add region/city structure model with validation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 );
|
||||
|
||||
208
viator-collector/includes/class-vc-structure.php
Normal file
208
viator-collector/includes/class-vc-structure.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
viator-collector/tests/run.php
Normal file
44
viator-collector/tests/run.php
Normal 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 );
|
||||
149
viator-collector/tests/test-structure.php
Normal file
149
viator-collector/tests/test-structure.php
Normal 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 );
|
||||
}
|
||||
Reference in New Issue
Block a user