diff --git a/build-zip.php b/build-zip.php index 331dee0..ec68df9 100644 --- a/build-zip.php +++ b/build-zip.php @@ -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 ); diff --git a/viator-collector/includes/class-vc-structure.php b/viator-collector/includes/class-vc-structure.php new file mode 100644 index 0000000..fadf350 --- /dev/null +++ b/viator-collector/includes/class-vc-structure.php @@ -0,0 +1,208 @@ + 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; + } +} diff --git a/viator-collector/tests/run.php b/viator-collector/tests/run.php new file mode 100644 index 0000000..2f4d23a --- /dev/null +++ b/viator-collector/tests/run.php @@ -0,0 +1,44 @@ +getMessage() . "\n"; + } +} + +echo $passed . ' passed, ' . $failed . " failed\n"; +exit( $failed ? 1 : 0 ); diff --git a/viator-collector/tests/test-structure.php b/viator-collector/tests/test-structure.php new file mode 100644 index 0000000..3acdc9f --- /dev/null +++ b/viator-collector/tests/test-structure.php @@ -0,0 +1,149 @@ + '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 ); +}