'Local / physical business', 'online' => 'Online-only business', 'saas' => 'SaaS / software', 'publisher' => 'Publisher / blog / media', 'creator' => 'Personal brand / creator', 'company_uk' => 'UK-registered company', 'other' => 'Other', ]; /** The user-selected business type (defaults to 'other' → broad eligibility). */ public static function business_type() { $t = Semantic_Settings::get( 'business_type', 'other' ); return in_array( $t, self::TYPES, true ) ? $t : 'other'; } /** * Canonical signal taxonomy. Each slot: * - family : identity (sameAs) | authority (corroboration anchor) | mention * - kind : linkable (paste a URL → writes to sameAs) | earned (guidance only) * - impact : rough weight for ranking next-best-actions (5 high … 1 low) * - match : host fragments that identify this platform in a URL * - types : 'all' or the business types for which this slot is ELIGIBLE * - capstone : Wikidata only — a threshold reached, never an action to perform */ private static function taxonomy() { return [ // ---- Identity (sameAs — "this *is* the org"). The site's own URL is the // centre node itself, so it is not a separate signal here. ---- 'x' => [ 'family' => 'identity', 'label' => 'X / Twitter', 'kind' => 'linkable', 'impact' => 4, 'match' => [ 'x.com', 'twitter.com' ], 'types' => 'all' ], 'linkedin' => [ 'family' => 'identity', 'label' => 'LinkedIn', 'kind' => 'linkable', 'impact' => 4, 'match' => [ 'linkedin.com' ], 'types' => 'all' ], 'instagram' => [ 'family' => 'identity', 'label' => 'Instagram', 'kind' => 'linkable', 'impact' => 3, 'match' => [ 'instagram.com' ], 'types' => [ 'local', 'online', 'publisher', 'creator', 'other' ] ], 'facebook' => [ 'family' => 'identity', 'label' => 'Facebook Page', 'kind' => 'linkable', 'impact' => 3, 'match' => [ 'facebook.com', 'fb.com' ], 'types' => [ 'local', 'online', 'publisher', 'creator', 'other' ] ], 'youtube' => [ 'family' => 'identity', 'label' => 'YouTube channel', 'kind' => 'linkable', 'impact' => 3, 'match' => [ 'youtube.com', 'youtu.be' ], 'types' => 'all' ], // ---- Authority / corroboration anchors ---- 'google_business' => [ 'family' => 'authority', 'label' => 'Google Business Profile', 'kind' => 'earned', 'impact' => 4, 'match' => [ 'business.google.com', 'g.page', 'google.com/maps' ], 'types' => [ 'local' ] ], 'crunchbase' => [ 'family' => 'authority', 'label' => 'Crunchbase', 'kind' => 'linkable', 'impact' => 3, 'match' => [ 'crunchbase.com' ], 'types' => [ 'saas', 'online', 'company_uk' ] ], 'companies_house' => [ 'family' => 'authority', 'label' => 'Companies House', 'kind' => 'linkable', 'impact' => 3, 'match' => [ 'company-information.service.gov.uk', 'companieshouse.gov.uk' ], 'types' => [ 'company_uk' ] ], 'wikipedia' => [ 'family' => 'authority', 'label' => 'Wikipedia', 'kind' => 'linkable', 'impact' => 4, 'match' => [ 'wikipedia.org' ], 'types' => 'all' ], 'wikidata' => [ 'family' => 'authority', 'label' => 'Wikidata', 'kind' => 'linkable', 'impact' => 4, 'match' => [ 'wikidata.org' ], 'types' => 'all' ], // ---- Mentions (outer ring; discovery is V2 — V1 shows earned guidance) ---- 'press' => [ 'family' => 'mention', 'label' => 'Press / news', 'kind' => 'earned', 'impact' => 4, 'match' => [], 'types' => 'all' ], 'directory' => [ 'family' => 'mention', 'label' => 'Directory listing', 'kind' => 'earned', 'impact' => 2, 'match' => [], 'types' => [ 'local', 'online', 'saas' ] ], 'forum' => [ 'family' => 'mention', 'label' => 'Forum / community', 'kind' => 'earned', 'impact' => 2, 'match' => [], 'types' => 'all' ], ]; } /** Short, honest "how to get this" guidance for earned slots. */ private static function guidance( $key ) { $g = [ 'google_business' => 'Claim your free Google Business Profile so Google can resolve your location and hours.', 'wikipedia' => 'Link your Wikipedia article if one exists.', 'wikidata' => 'Link your Wikidata item if you have one.', 'press' => 'Independent press or news coverage. Earned through outreach and newsworthy activity.', 'directory' => 'Listings in reputable industry or local directories corroborate who you are.', 'forum' => 'Genuine community mentions (e.g. Reddit) signal real-world recognition.', ]; return $g[ $key ] ?? ''; } /** Is a slot eligible for the current business type? */ private static function eligible( array $slot, $type ) { return 'all' === $slot['types'] || in_array( $type, (array) $slot['types'], true ); } /** Match a URL to a taxonomy slot key by host fragment, or null if unknown. */ private static function detect_platform( $url ) { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); $path = strtolower( (string) wp_parse_url( $url, PHP_URL_PATH ) ); $hay = $host . $path; if ( '' === $host ) return null; foreach ( self::taxonomy() as $key => $slot ) { foreach ( $slot['match'] as $frag ) { if ( false !== strpos( $hay, $frag ) ) return $key; } } return null; } /** * Label/glyph/colour for a profile link that isn't one of the canonical ghost * slots — popular platforms get a proper name + tint, anything else infers a * name from its domain. Lets a user add e.g. Bluesky and see it on the radar. * Returns [ label, glyph, color ]. */ private static function recognised_extra( $url ) { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); $map = [ 'bsky.app' => [ 'Bluesky', 'bs', '#1185fe' ], 'mastodon' => [ 'Mastodon', 'm', '#6364ff' ], 'github.com' => [ 'GitHub', 'gh', '#24292f' ], 'tiktok.com' => [ 'TikTok', 'tt', '#111111' ], 'threads.net' => [ 'Threads', '@', '#111111' ], 'pinterest.' => [ 'Pinterest', 'p', '#e60023' ], 'medium.com' => [ 'Medium', 'M', '#111111' ], 'g2.com' => [ 'G2', 'g2', '#ff492c' ], 'reddit.com' => [ 'Reddit', 'r', '#ff4500' ], ]; foreach ( $map as $frag => $info ) { if ( '' !== $host && false !== strpos( $host, $frag ) ) return $info; } $name = preg_replace( '/^www\./', '', $host ); $name = $name ? ucfirst( explode( '.', $name )[0] ) : 'Linked profile'; $glyph = strtoupper( substr( $name, 0, 2 ) ); return [ $name, $glyph ?: '↗', '#5A6A7A' ]; } /** * Validate a pasted URL before it can be written to public `sameAs`. Must be a * real URL, and for a known platform slot the host must actually match — a wrong * `sameAs` is a false identity claim, worse than a missing one. Returns * [ ok(bool), message(string) ]. */ public static function validate_link( $key, $url ) { $url = trim( (string) $url ); if ( '' === $url || ! wp_http_validate_url( $url ) ) { return [ false, 'Please enter a valid URL, including https://.' ]; } $tax = self::taxonomy(); if ( isset( $tax[ $key ] ) && ! empty( $tax[ $key ]['match'] ) && self::detect_platform( $url ) !== $key ) { return [ false, 'That URL doesn’t look like a ' . $tax[ $key ]['label'] . ' link.' ]; } return [ true, '' ]; } /** * The full profile object the admin UI renders: org node, active + eligible-ghost * signals, the readiness criteria, strength tier, labelled counts and ranked actions. */ public static function build_profile() { $type = self::business_type(); $taxonomy = self::taxonomy(); // --- Active identity signals from the existing org stores --- $active = []; // key => url (recognised slots) $extra = []; // unrecognised identity URLs (still count toward identity) $org_url = Semantic_Settings::org_url(); // centre node; not a counted signal foreach ( (array) Semantic_Settings::org_socials() as $u ) { $u = (string) $u; if ( '' === $u ) continue; $key = self::detect_platform( $u ); if ( $key ) { $active[ $key ] = $u; } else { $extra[] = $u; } } // --- Assemble the signal list: active recognised + eligible ghosts --- $signals = []; foreach ( $taxonomy as $key => $slot ) { $is_active = isset( $active[ $key ] ); if ( ! $is_active && ! self::eligible( $slot, $type ) ) continue; // hide ineligible gaps $signals[] = [ 'key' => $key, 'label' => $slot['label'], 'family' => $slot['family'], 'kind' => $slot['kind'], 'impact' => $slot['impact'], 'active' => $is_active, 'url' => $is_active ? $active[ $key ] : '', 'guidance' => $is_active ? '' : self::guidance( $key ), ]; } foreach ( $extra as $i => $u ) { list( $elabel, $eglyph, $ecolor ) = self::recognised_extra( $u ); $signals[] = [ 'key' => 'link_' . $i, 'label' => $elabel, 'family' => 'identity', 'kind' => 'linkable', 'impact' => 2, 'active' => true, 'url' => $u, 'guidance' => '', 'glyph' => $eglyph, 'color' => $ecolor, ]; } // --- Counts (labelled distinctly to avoid the "two denominators" bug) --- $identity_total = 0; $identity_active = 0; foreach ( $taxonomy as $key => $slot ) { if ( 'identity' !== $slot['family'] ) continue; if ( ! self::eligible( $slot, $type ) && ! isset( $active[ $key ] ) ) continue; $identity_total++; if ( isset( $active[ $key ] ) ) $identity_active++; } $identity_active += count( $extra ); // --- Readiness criteria (honest states; V1 can only truly verify some) --- $has_authority = (bool) array_filter( array_keys( $active ), function ( $k ) use ( $taxonomy ) { return isset( $taxonomy[ $k ] ) && 'authority' === $taxonomy[ $k ]['family']; } ); $machine_ready = Semantic_Settings::schema_output_enabled() && Semantic_Settings::endpoints_enabled(); $criteria = [ [ 'key' => 'identity', 'label' => 'Identity confirmed', 'status' => $identity_active >= 2 ? 'met' : ( $identity_active === 1 ? 'partial' : 'none' ), 'count' => $identity_active, 'detail' => 'You’ve listed external profiles.', ], [ 'key' => 'consistency', 'label' => 'Consistency', // A real cross-source check needs an external source (Wikidata/mentions). // V1 settings-only cannot verify it → report honestly, never a default tick. 'status' => 'pending', 'count' => null, 'detail' => 'Name, description & category aligned across linked sources. Verified once external sources are connected.', ], [ 'key' => 'corroboration', 'label' => 'Corroboration', 'status' => 'pending', 'count' => 0, 'detail' => 'Independent, non-self-published mentions. Discovered when web mention scanning is enabled.', ], [ 'key' => 'authority', 'label' => 'Authority anchors', 'status' => $has_authority ? 'met' : 'none', 'count' => null, 'detail' => 'Heavyweight references: Knowledge Graph, Crunchbase, Companies House, Wikipedia/Wikidata.', ], [ 'key' => 'machine_readability', 'label' => 'Machine-readability', 'status' => $machine_ready ? 'met' : 'none', 'count' => null, 'detail' => 'Your own JSON-LD, llms.txt and live endpoints published and valid.', ], ]; $criteria_met = count( array_filter( $criteria, function ( $c ) { return 'met' === $c['status']; } ) ); // --- Indicator fill (0–5) + tier (NO 0–100 score). The identity spine alone // maxes at the middle (developing); authority + corroboration carry it to strong. // 1 link → 1, 2–4 → 2, full 5-link spine → 3, then +1 authority, +1 corroboration. $id_seg = $identity_active >= 5 ? 3 : ( $identity_active >= 2 ? 2 : ( $identity_active >= 1 ? 1 : 0 ) ); $corrob_present = false; // independent mentions are a later (discovery) phase $filled = max( 0, min( 5, $id_seg + ( $has_authority ? 1 : 0 ) + ( $corrob_present ? 1 : 0 ) ) ); if ( $filled >= 4 ) { $tier = 'strong'; } elseif ( $filled >= 2 ) { $tier = 'developing'; } else { $tier = 'sparse'; } $tier_label = [ 'sparse' => 'Sparse', 'developing' => 'Developing', 'strong' => 'Strong' ][ $tier ]; // --- Ranked next-best-actions: eligible ghosts by impact --- $actions = array_values( array_filter( $signals, function ( $s ) { return ! $s['active']; } ) ); usort( $actions, function ( $a, $b ) { return $b['impact'] <=> $a['impact']; } ); $actions = array_slice( $actions, 0, 5 ); return [ 'org' => [ 'name' => Semantic_Settings::org_name(), 'url' => $org_url, 'type' => $type, 'type_label' => self::TYPE_LABELS[ $type ], ], 'strength' => [ 'tier' => $tier, 'label' => $tier_label, 'filled' => $filled ], 'signals' => $signals, 'criteria' => $criteria, 'counts' => [ 'platforms_connected' => $identity_active, 'platforms_total' => max( $identity_total, $identity_active ), 'criteria_met' => $criteria_met, 'criteria_total' => count( $criteria ), ], 'actions' => $actions, ]; } }