$model ) { $model_labels[ $key ] = (string) $model['label']; } wp_localize_script( 'ai-o-optimiser-site-profile-guidance', 'AIOOptimiserSiteProfileGuidance', array( 'compatibility' => self::compatibility_matrix( false ), 'models' => $model_labels, 'domains' => AI_O_Optimiser_Site_Profile_UI::domains(), 'families' => self::entity_families(), 'currentFamily' => self::validated_current_family( $current ), 'labels' => array( 'recommended' => __( 'Common pairing', 'ai-o-optimiser' ), 'compatible' => __( 'Compatible', 'ai-o-optimiser' ), 'review' => __( 'Review setup', 'ai-o-optimiser' ), 'recommendedReason' => /* translators: Placeholder values are replaced with contextual values shown in this message. */ __( '%1$s + %2$s is a common way to describe this kind of site. The model describes how the site works; the domain describes what it is mainly about.', 'ai-o-optimiser' ), 'compatibleReason' => /* translators: Placeholder values are replaced with contextual values shown in this message. */ __( '%1$s + %2$s can still be valid because operating model and subject are separate dimensions. Confirm this reflects the real site before saving.', 'ai-o-optimiser' ), 'familyTitle' => __( '3. Which common entity family best fits?', 'ai-o-optimiser' ), 'familyCopy' => __( 'Optional. Choose a familiar entity family to make the setup easier to understand. This does not select or enforce a Schema.org type.', 'ai-o-optimiser' ), 'familyNone' => __( 'Not specified / choose exact types in Schema', 'ai-o-optimiser' ), 'examples' => __( 'Schema.org examples', 'ai-o-optimiser' ), 'noFamilies' => __( 'No common family is required for this domain. Use Schema semantic mappings when a specific entity type is needed.', 'ai-o-optimiser' ), ), ) ); } /** * Model/domain compatibility guidance. * * Model and domain intentionally describe different dimensions, so most * combinations are legitimate. The matrix highlights common combinations; * uncommon pairs remain compatible rather than being falsely blocked. * * @param bool $include_reason Include human-readable reason strings. * @return array */ public static function compatibility_matrix( $include_reason = true ) { $common = array( 'publisher_media' => array( 'general', 'technology_software', 'professional_services', 'education', 'healthcare', 'hospitality_travel', 'food_dining', 'property_real_estate', 'jobs_recruitment', 'events_entertainment', 'retail_products', 'nonprofit_membership', 'local_places' ), 'editorial_content' => array( 'general', 'technology_software', 'professional_services', 'education', 'healthcare', 'fitness_wellness', 'hospitality_travel', 'food_dining', 'property_real_estate', 'jobs_recruitment', 'events_entertainment', 'retail_products', 'nonprofit_membership', 'local_places' ), 'commerce_marketplace' => array( 'retail_products', 'food_dining', 'hospitality_travel', 'property_real_estate', 'events_entertainment', 'technology_software' ), 'directory_listings' => array( 'education', 'healthcare', 'fitness_wellness', 'hospitality_travel', 'food_dining', 'property_real_estate', 'jobs_recruitment', 'events_entertainment', 'local_places', 'professional_services' ), 'service_business' => array( 'professional_services', 'technology_software', 'education', 'healthcare', 'fitness_wellness', 'property_real_estate', 'jobs_recruitment' ), 'software_platform' => array( 'technology_software', 'professional_services', 'education', 'healthcare', 'fitness_wellness', 'hospitality_travel', 'food_dining', 'property_real_estate', 'jobs_recruitment', 'events_entertainment', 'retail_products', 'local_places' ), 'organisation_community' => array( 'nonprofit_membership', 'education', 'healthcare', 'professional_services', 'events_entertainment', 'local_places' ), 'local_place_business' => array( 'professional_services', 'healthcare', 'fitness_wellness', 'hospitality_travel', 'food_dining', 'property_real_estate', 'retail_products', 'local_places', 'education' ), 'mixed_general' => array( 'general' ), ); $out = array(); foreach ( AI_O_Optimiser_Site_Profile_UI::site_models() as $model => $model_data ) { foreach ( AI_O_Optimiser_Site_Profile_UI::domains() as $domain => $domain_label ) { $is_common = in_array( $domain, $common[ $model ] ?? array(), true ); $item = array( 'state' => $is_common ? 'recommended' : 'compatible' ); if ( $include_reason ) { $item['reason'] = $is_common ? sprintf( /* translators: Placeholder values are replaced with contextual values shown in this message. */ __( '%1$s + %2$s is a common way to describe this kind of site. The model describes how the site works; the domain describes what it is mainly about.', 'ai-o-optimiser' ), $model_data['label'], $domain_label ) : sprintf( /* translators: Placeholder values are replaced with contextual values shown in this message. */ __( '%1$s + %2$s can still be valid because operating model and subject are separate dimensions. Confirm this reflects the real site before saving.', 'ai-o-optimiser' ), $model_data['label'], $domain_label ); } $out[ $model ][ $domain ] = $item; } } return $out; } /** Common entity families for progressive disclosure. Not schema rules. */ public static function entity_families() { return array( 'organisation' => self::family( __( 'Organisation / business', 'ai-o-optimiser' ), __( 'General organisations or businesses.', 'ai-o-optimiser' ), array( 'general' ), array( 'Organization', 'Corporation' ) ), 'person' => self::family( __( 'Person / professional', 'ai-o-optimiser' ), __( 'A person, professional profile or individual expert.', 'ai-o-optimiser' ), array( 'general', 'professional_services' ), array( 'Person' ) ), 'place' => self::family( __( 'Place / location', 'ai-o-optimiser' ), __( 'A physical place or location-led record.', 'ai-o-optimiser' ), array( 'general', 'local_places' ), array( 'Place' ) ), 'software_application' => self::family( __( 'Software / application', 'ai-o-optimiser' ), __( 'Software, SaaS, applications or digital products.', 'ai-o-optimiser' ), array( 'technology_software' ), array( 'SoftwareApplication', 'WebApplication', 'MobileApplication' ) ), 'technology_service' => self::family( __( 'Technology service', 'ai-o-optimiser' ), __( 'IT, technology consulting, hosting or implementation services.', 'ai-o-optimiser' ), array( 'technology_software', 'professional_services' ), array( 'Service', 'ProfessionalService' ) ), 'consultancy_agency' => self::family( __( 'Consultancy / agency', 'ai-o-optimiser' ), __( 'Consultancies, agencies and expert service providers.', 'ai-o-optimiser' ), array( 'professional_services' ), array( 'ProfessionalService', 'Service' ) ), 'legal_financial' => self::family( __( 'Legal / financial practice', 'ai-o-optimiser' ), __( 'Law, accounting, financial and related professional practices.', 'ai-o-optimiser' ), array( 'professional_services' ), array( 'LegalService', 'FinancialService', 'AccountingService' ) ), 'trade_home_service' => self::family( __( 'Trade / home service', 'ai-o-optimiser' ), __( 'Trades and local services delivered to homes or businesses.', 'ai-o-optimiser' ), array( 'professional_services', 'local_places' ), array( 'HomeAndConstructionBusiness', 'ProfessionalService' ) ), 'school' => self::family( __( 'School / education provider', 'ai-o-optimiser' ), __( 'Schools and education providers.', 'ai-o-optimiser' ), array( 'education' ), array( 'School', 'EducationalOrganization' ) ), 'early_learning' => self::family( __( 'Early learning / childcare', 'ai-o-optimiser' ), __( 'Preschool, childcare and early-learning providers.', 'ai-o-optimiser' ), array( 'education' ), array( 'Preschool', 'ChildCare' ) ), 'higher_education' => self::family( __( 'College / university', 'ai-o-optimiser' ), __( 'Higher-education institutions.', 'ai-o-optimiser' ), array( 'education' ), array( 'CollegeOrUniversity', 'EducationalOrganization' ) ), 'training_course' => self::family( __( 'Training / course', 'ai-o-optimiser' ), __( 'Training providers and course-led content.', 'ai-o-optimiser' ), array( 'education' ), array( 'Course', 'EducationalOrganization' ) ), 'medical_clinic' => self::family( __( 'Clinic / medical practice', 'ai-o-optimiser' ), __( 'Clinics and medical practices.', 'ai-o-optimiser' ), array( 'healthcare' ), array( 'MedicalClinic', 'Physician' ) ), 'dental_practice' => self::family( __( 'Dentist / dental practice', 'ai-o-optimiser' ), __( 'Dental practices and dentist-led services.', 'ai-o-optimiser' ), array( 'healthcare' ), array( 'Dentist' ) ), 'hospital_health_service' => self::family( __( 'Hospital / health service', 'ai-o-optimiser' ), __( 'Hospitals and larger health-service organisations.', 'ai-o-optimiser' ), array( 'healthcare' ), array( 'Hospital', 'MedicalOrganization' ) ), 'pharmacy' => self::family( __( 'Pharmacy', 'ai-o-optimiser' ), __( 'Pharmacies and pharmacy-led local businesses.', 'ai-o-optimiser' ), array( 'healthcare', 'retail_products' ), array( 'Pharmacy' ) ), 'gym_health_club' => self::family( __( 'Gym / health club', 'ai-o-optimiser' ), __( 'Gyms, fitness centres and health clubs.', 'ai-o-optimiser' ), array( 'fitness_wellness' ), array( 'ExerciseGym', 'HealthClub' ) ), 'wellness_service' => self::family( __( 'Wellness service', 'ai-o-optimiser' ), __( 'Wellness, spa and related service businesses.', 'ai-o-optimiser' ), array( 'fitness_wellness' ), array( 'HealthAndBeautyBusiness', 'DaySpa' ) ), 'sports_facility' => self::family( __( 'Sports facility', 'ai-o-optimiser' ), __( 'Sports centres, clubs and activity venues.', 'ai-o-optimiser' ), array( 'fitness_wellness', 'events_entertainment' ), array( 'SportsActivityLocation', 'SportsClub' ) ), 'hotel_lodging' => self::family( __( 'Hotel / lodging', 'ai-o-optimiser' ), __( 'Hotels, motels and lodging businesses.', 'ai-o-optimiser' ), array( 'hospitality_travel' ), array( 'Hotel', 'Motel', 'LodgingBusiness' ) ), 'resort' => self::family( __( 'Resort', 'ai-o-optimiser' ), __( 'Resort-style accommodation and destination properties.', 'ai-o-optimiser' ), array( 'hospitality_travel' ), array( 'Resort', 'LodgingBusiness' ) ), 'hostel_short_stay' => self::family( __( 'Hostel / short stay', 'ai-o-optimiser' ), __( 'Hostels and other short-stay accommodation.', 'ai-o-optimiser' ), array( 'hospitality_travel' ), array( 'Hostel', 'LodgingBusiness' ) ), 'travel_tourism' => self::family( __( 'Travel / tourism service', 'ai-o-optimiser' ), __( 'Travel agencies, tours and tourism services.', 'ai-o-optimiser' ), array( 'hospitality_travel' ), array( 'TravelAgency' ) ), 'restaurant' => self::family( __( 'Restaurant', 'ai-o-optimiser' ), __( 'Restaurants and dining venues.', 'ai-o-optimiser' ), array( 'food_dining' ), array( 'Restaurant', 'FoodEstablishment' ) ), 'cafe_bar' => self::family( __( 'Café / bar', 'ai-o-optimiser' ), __( 'Cafés, coffee shops, bars and pubs.', 'ai-o-optimiser' ), array( 'food_dining' ), array( 'CafeOrCoffeeShop', 'BarOrPub' ) ), 'food_service' => self::family( __( 'Food service', 'ai-o-optimiser' ), __( 'Catering, takeaway and other food-service businesses.', 'ai-o-optimiser' ), array( 'food_dining' ), array( 'FoodEstablishment', 'FoodService' ) ), 'real_estate_agency' => self::family( __( 'Real estate agency', 'ai-o-optimiser' ), __( 'Property agencies and real-estate service businesses.', 'ai-o-optimiser' ), array( 'property_real_estate' ), array( 'RealEstateAgent' ) ), 'property_listing' => self::family( __( 'Property listing', 'ai-o-optimiser' ), __( 'Individual property or accommodation listings.', 'ai-o-optimiser' ), array( 'property_real_estate' ), array( 'Accommodation' ) ), 'job_board' => self::family( __( 'Job board / vacancies', 'ai-o-optimiser' ), __( 'Sites or sections centred on individual job opportunities.', 'ai-o-optimiser' ), array( 'jobs_recruitment' ), array( 'JobPosting' ) ), 'recruitment_agency' => self::family( __( 'Recruitment agency', 'ai-o-optimiser' ), __( 'Recruiters and employment-service businesses.', 'ai-o-optimiser' ), array( 'jobs_recruitment', 'professional_services' ), array( 'EmploymentAgency', 'ProfessionalService' ) ), 'employer_careers' => self::family( __( 'Employer careers', 'ai-o-optimiser' ), __( 'Employer-owned careers and vacancy content.', 'ai-o-optimiser' ), array( 'jobs_recruitment' ), array( 'Organization', 'JobPosting' ) ), 'event_listing' => self::family( __( 'Events', 'ai-o-optimiser' ), __( 'Individual events and event listings.', 'ai-o-optimiser' ), array( 'events_entertainment' ), array( 'Event' ) ), 'venue' => self::family( __( 'Venue', 'ai-o-optimiser' ), __( 'Entertainment, event and performance venues.', 'ai-o-optimiser' ), array( 'events_entertainment', 'local_places' ), array( 'EventVenue', 'EntertainmentBusiness' ) ), 'product_catalogue' => self::family( __( 'Products / catalogue', 'ai-o-optimiser' ), __( 'Individual products or product-led catalogues.', 'ai-o-optimiser' ), array( 'retail_products' ), array( 'Product' ) ), 'store' => self::family( __( 'Store / shop', 'ai-o-optimiser' ), __( 'Physical or online retail stores.', 'ai-o-optimiser' ), array( 'retail_products', 'local_places' ), array( 'Store' ) ), 'marketplace' => self::family( __( 'Marketplace', 'ai-o-optimiser' ), __( 'Multi-seller or aggregated product marketplaces.', 'ai-o-optimiser' ), array( 'retail_products' ), array( 'CollectionPage', 'Product' ) ), 'nonprofit_org' => self::family( __( 'Nonprofit / charity', 'ai-o-optimiser' ), __( 'Nonprofit, charitable and mission-led organisations.', 'ai-o-optimiser' ), array( 'nonprofit_membership' ), array( 'NGO', 'Organization' ) ), 'association_membership' => self::family( __( 'Association / membership body', 'ai-o-optimiser' ), __( 'Associations, clubs and membership organisations.', 'ai-o-optimiser' ), array( 'nonprofit_membership' ), array( 'Organization' ) ), 'local_business' => self::family( __( 'Local business', 'ai-o-optimiser' ), __( 'A local business where location or service area matters.', 'ai-o-optimiser' ), array( 'local_places', 'general' ), array( 'LocalBusiness' ) ), 'attraction_destination' => self::family( __( 'Attraction / destination', 'ai-o-optimiser' ), __( 'Attractions, destinations and visitor places.', 'ai-o-optimiser' ), array( 'local_places', 'hospitality_travel' ), array( 'TouristAttraction', 'Place' ) ), ); } /** * Build one family record. * * The label and description arrive already translated. They used to arrive * as bare strings and be passed to __() here, under an inline suppression * that silenced the sniff and missed the actual point: the translation * parser reads source without running it, so a variable inside __() means * the string never reaches translate.wordpress.org at all. Forty families, * eighty strings, none of them translatable. They are literals at each call * site now, where the parser can see them. */ private static function family( $label, $description, $domains, $examples ) { return array( 'label' => $label, 'description' => $description, 'domains' => array_values( $domains ), 'examples' => array_values( $examples ), ); } /** Validate and persist the optional family before the existing save handler. */ public static function handle_pre_save() { if ( ! current_user_can( self::capability() ) ) { return; } check_admin_referer( 'ai_o_optimiser_save_site_profile_hierarchy' ); $model = isset( $_POST['site_model'] ) ? sanitize_key( wp_unslash( $_POST['site_model'] ) ) : ''; $domain = isset( $_POST['primary_domain'] ) ? sanitize_key( wp_unslash( $_POST['primary_domain'] ) ) : ''; if ( ! isset( AI_O_Optimiser_Site_Profile_UI::site_models()[ $model ] ) || ! isset( AI_O_Optimiser_Site_Profile_UI::domains()[ $domain ] ) ) { return; // Existing handler owns the canonical invalid-selection error. } $family = isset( $_POST['entity_family'] ) ? sanitize_key( wp_unslash( $_POST['entity_family'] ) ) : ''; if ( $family && ! self::family_matches_domain( $family, $domain ) ) { wp_die( esc_html__( 'The selected common entity family does not belong to the chosen primary domain. Choose a matching family or leave it unspecified.', 'ai-o-optimiser' ) ); } $value = get_option( AI_O_Optimiser_Site_Profile::OPTION, array() ); $value = is_array( $value ) ? $value : array(); if ( ! $family && ! empty( $value['entity_family'] ) && ! self::family_matches_domain( sanitize_key( $value['entity_family'] ), $domain ) ) { $value['entity_family'] = ''; } elseif ( isset( $_POST['entity_family'] ) ) { $value['entity_family'] = $family; } if ( false === get_option( AI_O_Optimiser_Site_Profile::OPTION, false ) ) { add_option( AI_O_Optimiser_Site_Profile::OPTION, $value, '', 'no' ); } else { update_option( AI_O_Optimiser_Site_Profile::OPTION, $value, false ); } AI_O_Optimiser_Site_Profile::reset_cache(); } /** Is the selected family valid for this domain? */ public static function family_matches_domain( $family, $domain ) { $families = self::entity_families(); return isset( $families[ $family ] ) && in_array( $domain, $families[ $family ]['domains'], true ); } /** Current family only when it remains valid for the saved domain. */ private static function validated_current_family( $current ) { $family = ! empty( $current['entity_family'] ) ? sanitize_key( $current['entity_family'] ) : ''; $domain = ! empty( $current['primary_domain'] ) ? sanitize_key( $current['primary_domain'] ) : ''; return $family && self::family_matches_domain( $family, $domain ) ? $family : ''; } private static function current_page() { return isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen state (page slug, tab, redirect result notices). Nothing is written here, so there is no form submission to carry a nonce. } private static function is_profile_screen() { return in_array( self::current_page(), array( 'ai-o-optimiser-dashboard', 'ai-o-optimiser-schema-review', 'ai-o-optimiser-settings' ), true ); } private static function capability() { return defined( 'AI_O_Optimiser_SENSITIVE_CAPABILITY' ) ? AI_O_Optimiser_SENSITIVE_CAPABILITY : 'manage_options'; } }