directories = jvbGlobalDirectoryInfo(); // Output schema in the head add_action('wp_head', [$this, 'outputSchema'], 1); // Disable The SEO Framework schema for our custom content add_filter('the_seo_framework_schema_graph_data', [$this, 'disableTSFSchema'], 10, 2); // Make the getBreadcrumbSchema method available for the visual breadcrumbs add_filter('jvb_breadcrumb_schema', [$this, 'getBreadcrumbSchema']); add_action('init', [$this, 'init']); } /** * @return void */ public function init():void { // Filter post types $this->content = array_keys(JVB_CONTENT); // Filter term taxonomies $this->taxonomies = array_keys(JVB_TAXONOMY); } /** * Disable The SEO Framework schema on our custom content * @param array $graph * @param array|null $args * * @return array */ public function disableTSFSchema(array $graph, array|null $args):array { $tsf = tsf(); if (null === $args) { // We're in the loop. if ($tsf->query()->is_real_front_page()) { return []; } if ($tsf->query()->is_singular() && in_array($tsf->query()->get_current_post_type(), array_map(function ($content) { return jvbCheckBase($content); }, array_keys(JVB_CONTENT)))) { return []; } elseif ($tsf->query()->is_tax() && in_array($tsf->query()->get_current_taxonomy(), [ BASE.'style', BASE.'type', BASE.'theme', BASE.'city', BASE.'artstyle', BASE.'arttheme', BASE.'artform', BASE.'artmedium', BASE.'pstyle', ])) { return []; } } return $graph; } /** * Output schema based on current context * @return void */ public function outputSchema():void { // Base schema that's included on every page $schema = [ '@context' => 'https://schema.org', '@graph' => [ $this->getWebsiteSchema(), ] ]; // Add context-specific schema if (jvbIsDirectory()) { $schema['@graph'][] = $this->getDirectorySchema(); } elseif (is_front_page()) { $schema['@graph'][] = $this->getHomeSchema(); } elseif (is_singular(BASE.'artist')) { $schema['@graph'][] = $this->getArtistSchema(get_the_ID()); } elseif (is_singular(BASE.'partner')) { $schema['@graph'][] = $this->getPartnerSchema(get_the_ID()); } elseif (is_tax(BASE.'shop')) { $schema['@graph'][] = $this->getShopSchema(get_queried_object_id()); } elseif (is_tax(BASE.'style')) { $schema['@graph'][] = $this->getStyleSchema(get_queried_object_id()); } elseif (is_tax(BASE.'theme')) { $schema['@graph'][] = $this->getThemeSchema(get_queried_object_id()); } elseif (is_front_page()) { $schema['@graph'][] = $this->getHomeSchema(); } // Add breadcrumbs to all pages $breadcrumbs = $this->getBreadcrumbSchema(); if (!empty($breadcrumbs)) { $schema['@graph'][] = $breadcrumbs; } echo ''; echo ''; } /** * Get base Website schema * @return array Schema data */ private function getWebsiteSchema():array { return [ '@type' => 'WebSite', '@id' => get_home_url() . '/#website', 'name' => get_bloginfo('name'), 'url' => get_home_url(), 'description' => 'Your tattoo scene on your screen.', 'inLanguage' => 'en-CA', 'publisher' => $this->getLegacyOrganizationReference(), ]; } /** * Get Legacy Tattoo Removal reference for publisher * This is critical for SEO to link back to the main business * @return array Schema data */ private function getLegacyOrganizationReference():array { return [ '@type' => 'Organization', '@id' => 'https://legacytattooremoval.ca/#organization', 'name' => 'Legacy Tattoo Removal', 'url' => 'https://legacytattooremoval.ca', 'sameAs' => [ 'https://www.instagram.com/legacytattooremoval', 'https://www.facebook.com/legacytattooremoval', 'https://www.tiktok.com/@legacytattooremoval', 'https://bsky.app/profile/legacytattooremoval.ca' ], 'logo' => [ '@type' => 'ImageObject', 'url' => 'https://legacytattooremoval.ca/wp-content/uploads/2024/09/legacy-tattoo-removal.webp' ] ]; } /** * Get relationship schema between edmonton.ink and Legacy * @return array Schema data */ private function getLegacyRelationshipSchema():array { return [ '@type' => 'Organization', '@id' => get_home_url() . '/#organization', 'name' => 'edmonton.ink', 'url' => get_home_url(), 'memberOf' => [ '@id' => 'https://edmonton.ink/#organization', 'publisher' => [ '@id' => 'https://legacytattooremoval.ca/#organization' ] ], ]; } /** * Get Breadcrumb schema * @return array Schema data */ public function getBreadcrumbSchema():array { // Use the existing jvbGetCrumbs function $crumbs = jvbGetCrumbs(); if (empty($crumbs)) { return []; } // Format for schema $items = []; foreach ($crumbs as $index => $crumb) { // Make sure we have a URL, even if it's just a placeholder for current page $url = (!empty($crumb['url'])) ? $crumb['url'] : get_permalink(); // For icon-only breadcrumbs, use the name property $name = $crumb['name']; if (isset($crumb['icon']) && empty(strip_tags($name))) { $name = strip_tags($crumb['name']); } $items[] = [ '@type' => 'ListItem', 'position' => $index + 1, 'name' => $name, 'item' => $url ]; } return [ '@type' => 'BreadcrumbList', '@id' => get_permalink() . '#breadcrumb', 'itemListElement' => $items ]; } /** * Get Artist schema * @param int $post_id The artist post ID * @return array Schema data */ private function getArtistSchema(int $post_id):array { $meta = new MetaManager($post_id, 'post'); $metaValues = $meta->getAll(); $permalink = get_permalink($post_id); // Get artist data $name = get_the_title($post_id); $first_name = $meta->getValue('first_name'); $bio = $meta->getValue('bio'); $short_bio = $meta->getValue('short_bio'); $description = $short_bio ?: wp_strip_all_tags($bio) ?: get_the_excerpt($post_id); // Build person schema $schema = array_merge( [ '@type' => 'Person', '@id' => $permalink . '#person', 'name' => $name, 'givenName' => $first_name, 'description' => $description, 'url' => $permalink, 'jobTitle' => jvbArtistType($post_id), 'memberOf' => [ '@id' => 'https://edmonton.ink/#organization' ] ], $this->getContact($metaValues), $this->getLinks($metaValues), $this->getRatings($metaValues), $this->getReviews($metaValues), $this->getAlternateName($metaValues), $this->getCities($metaValues), $this->getSpecialties($metaValues), $this->getRate($metaValues), $this->getAwards($metaValues), $this->getServices($metaValues), $this->getLanguages($metaValues), $this->getCredentials($metaValues), $this->getKeywords($metaValues), ); // Add image if available $image_id = $metaValues['image_portrait']??false; if ($image_id) { $image_url = wp_get_attachment_image_url($image_id, 'full'); if ($image_url) { $schema['image'] = $image_url; } } // Add shop affiliation $shops = get_the_terms($post_id, BASE.'shop'); if (!empty($shops) && !is_wp_error($shops)) { $worksFor = []; foreach ($shops as $shop) { $worksFor[] = [ '@type' => 'Organization', 'name' => $shop->name, 'url' => get_term_link($shop), 'memberOf' => [ '@id' => 'https://edmonton.ink.ca/#organization' ] ]; } $schema['worksFor'] = count($worksFor) == 1 ? $worksFor[0] : $worksFor; } // Add styles as skills $styles = get_the_terms($post_id, BASE.'style'); if (!empty($styles) && !is_wp_error($styles)) { $skills = []; foreach ($styles as $style) { $skills[] = $style->name; } if (!empty($skills)) { $schema['knowsAbout'] = $skills; } } // Add latest portfolio works $work = get_posts([ 'post_type' => [BASE.'tattoo', BASE.'piercing', BASE.'artwork'], 'author' => get_post_meta($post_id, BASE . 'link', true), 'posts_per_page' => 10 ]); if (!empty($work)) { $works = []; foreach ($work as $w) { $img = get_post_thumbnail_id($w->ID); $imgURL = wp_get_attachment_image_url($img, 'full'); $works[] = [ '@type' => 'CreativeWork', 'name' => $w->post_title?:str_replace(BASE, '', $w->post_type).' by '.$name, 'image' => $imgURL?: '', 'url' => get_permalink($w->ID), 'datePublished' => get_the_date('c', $w->ID), ]; } if (!empty($works)) { $schema['creator'] = $works; } } return $schema; } /** * Get Shop schema * @param int $term_id The shop term ID * @return array Schema data */ private function getShopSchema(int $term_id): array { $meta = new MetaManager($term_id, 'term'); $metaValues = $meta->getAll(); $term = get_term($term_id, BASE.'shop'); $permalink = get_term_link($term_id, BASE.'shop'); // Basic shop information $schema = array_merge( [ '@type' => 'LocalBusiness', '@id' => $permalink . '#organization', 'name' => html_entity_decode($term->name), 'description' => $meta->getValue('short_bio') ?: $term->description, 'url' => $permalink, 'priceRange' => '$$', // Default price range 'additionalType' => 'https://schema.org/TattooParlor', // Custom business type 'memberOf' => [ '@id' => 'https://edmonton.ink/#organization' ] ], $this->getAlternateName($metaValues), $this->getLinks($metaValues), $this->getSlogan($metaValues), $this->getContact($metaValues), $this->getLocation($metaValues), $this->getCities($metaValues), $this->getHours($metaValues), $this->getSpecialties($metaValues), $this->getRate($metaValues), $this->getAwards($metaValues), $this->getRatings($metaValues), $this->getReviews($metaValues), $this->getServices($metaValues), $this->getLanguages($metaValues), $this->getPaymentAccepted($metaValues), $this->getAmenities($metaValues), $this->getCredentials($metaValues), $this->getKeywords($metaValues), $this->getDissolutionDate($metaValues), ); // Add founding date if available $established = $metaValues['established']; if ($established) { $schema['foundingDate'] = $established . '-01-01'; // Approximate to January 1st } // Add image $image_id = $metaValues['image']; if ($image_id) { $image_url = wp_get_attachment_image_url($image_id, 'full'); if ($image_url) { $schema['image'] = $image_url; $schema['logo'] = $image_url; } } // Get artists working at this shop $artists = $this->getArtists($term_id, BASE.'shop'); if (!empty($artists)) { $schema['employee'] = $artists; } return $schema; } /** * @param array $meta * * @return array */ private function getLocation(array $meta):array { // Add location data $location = $meta['location']; $schema = []; if ($location && !empty($location['address'])) { $schema['address'] = [ '@type' => 'PostalAddress', 'streetAddress' => $location['address'] ]; if (!empty($location['lat']) && !empty($location['lng'])) { $schema['geo'] = [ '@type' => 'GeoCoordinates', 'latitude' => $location['lat'], 'longitude' => $location['lng'] ]; } } return $schema; } /** * @param array $meta * * @return array */ private function getContact(array $meta):array { $contact = $meta['public_contact']??''; if (!is_array($contact)) { $contact = explode(',', $contact); } $return = []; if (!empty($contact)) { foreach ($contact as $c) { switch ($c) { case 'text': case 'call': $phone = $meta['phone']??''; if ($phone !== '') { $return['telephone'] = $phone; } break; case 'email': $email = $meta['email']??''; if ($email !== '') { $return['email'] = $email; } break; } } } return $return; } /** * @param array $meta * * @return array */ private function getAlternateName(array $meta):array { return [ 'alternateName' => $meta['alternate_name'] ]; } /** * @param array $meta * * @return array */ private function getCities(array $meta):array{ $get = $meta['city']; $areas = []; if (!is_array($get)) { $get = explode(',', $get); } if (!empty($get)) { foreach ($get as $g) { $city = get_term($g, BASE.'city'); if (!$city || is_wp_error($city)) { return []; } $areas[] = [ '@type' => 'City', 'name' => $city->name, '@id' => get_term_meta($city->term_id, BASE.'wiki') ]; } if (!empty($areas)) { $areas = [ 'areaServed' => $areas ]; } } return $areas; } /** * @param array $meta * * @return array */ private function getLinks(array $meta):array { $get = $meta['links']??[]; $links = []; if (!empty($get)) { foreach ($get as $g) { $links[] = $g['url']; } if (!empty($links)) { $links = [ 'sameAs' => $links ]; } } return $links; } /** * @param array $meta * * @return array */ private function getSpecialties(array $meta):array { $specialties = []; $get = $meta['specialties']??[]; if (!empty($get)) { foreach ($get as $g) { $temp = [ '@type' => 'Specialty', 'name' => $g['specialty'] ]; if ($g['description'] !== '') { $temp['description'] = $g['description']; } $specialties[] = $temp; } if (!empty($specialties)) { $specialties = [ 'specialties' => $specialties ]; } } return $specialties; } /** * @param array $meta * * @return array */ private function getRate(array $meta):array { $get = $meta['rate']??0; if ($get > 0) { return [ '@type' => 'PropertyValue', 'name' => 'Shop Rate (per hour)', 'value' => $get ]; } return []; } /** * @param array $meta * * @return array */ private function getAwards(array $meta):array { $get = $meta['awards']??[]; $awards = []; if (!empty($get)) { foreach ($get as $g) { $awards[] = [ '@type' => 'Award', 'name' => $g['name'], 'description' => 'Voted '.$g['name'].' at the '.$g['year'].' '.$g['presenter'], 'dateAwarded' =>$g['year'], ]; } if (!empty($awards)) { $awards = [ 'awards' => $awards ]; } } return $awards; } /** * @param array $meta * * @return array */ private function getRatings(array $meta):array { $out = []; $rating = $meta['average_rating']??'none'; if ($rating !== 'none') { $total = $meta['total_ratings']??0; $out = [ 'aggregateRating' => [ '@type' =>'AggregateRating', 'ratingValue' =>$rating, 'ratingCount' => ($total === '') ? 1 : $total, 'bestRating' => 5, 'worstRating' => 1, ] ]; } return $out; } /** * @param array $meta * * @return array */ private function getReviews(array $meta):array { $get = $meta['reviews']; $reviews = []; if (!empty($get)) { foreach ($get as $g) { $temp = [ '@type' => 'Review', 'author' => [ '@type' => 'Person', 'name' => $g['name'] ], 'datePublished' => $g['date'], 'reviewBody' =>$g['review'], ]; if ($g['rating'] !== 'none') { $temp['reviewRating'] = [ '@type' =>'ReviewRating', 'ratingValue' => $g['rating'], 'bestRating' => 5, 'worstRating' => 1, ]; } if ($g['url'] !== '') { $temp['url']= $g['url']; } $reviews[] = $temp; } if (!empty($reviews)) { $reviews = [ 'review' => $reviews ]; } } return $reviews; } /** * @param array $meta * * @return array */ private function getServices(array $meta):array { $get = $meta['services']??[]; $services = []; if (!empty($get)) { foreach ($get as $g) { $temp = [ '@type' => 'Service', 'itemOffered' =>[ '@type' =>'Service', 'name' => $g['name'] ] ]; if ($g['description'] !== '') { $temp['itemOffered']['description'] = $g['description']; } $services[] = $temp; } if (!empty($services)) { $services = [ '@type' => 'OfferCatalog', 'name' => 'Services', 'itemListElement' => $services ]; } } return $services; } /** * @param array $meta * * @return array */ private function getLanguages(array $meta):array { $get = $meta['languages']??[]; $languages = []; if (!empty($get)) { foreach ($get as $g) { $languages[] = [ '@type' => 'Language', 'name' => $g['language'] ]; } if (!empty($languages)) { $languages = [ 'availableLanguage' => $languages ]; } } return $languages; } /** * @param array $meta * * @return array */ private function getKeywords(array $meta):array { $get = $meta['keywords']; $keywords = [ 'Edmonton Tattoo Artist', 'Tattoo', 'Edmonton Tattoo' ]; if (!empty($get)) { foreach ($get as $g) { $keywords[] = $g['keyword']; } } if (!empty($keywords)) { $keywords = [ 'keywords' => $keywords ]; } return $keywords; } /** * @param array $meta * * @return array */ private function getHours(array $meta):array { $hours = $meta['hours']??[]; $return = []; if (!empty($hours)) { foreach ($hours as $hour) { $days = jvbCondenseDayRange(explode(',', $hour['days'])); $return[] = $days.' '.$hour['time_open'].'-'.$hour['time_closes']; } } return [ 'openingHours' => $return ]; } /** * @param array $meta * * @return array */ private function getSlogan(array $meta):array { if (!array_key_exists('slogan', $meta) || $meta['slogan'] === ''){ return []; } return [ 'slogan' => $meta['slogan'] ]; } /** * @param array $meta * * @return array */ private function getPaymentAccepted(array $meta):array { return [ 'paymentAccepted' => $meta['payment'] ]; } /** * @param array $meta * * @return array */ private function getAmenities(array $meta):array { $amenities = []; $get = $meta['amenities']??[]; if (!is_array($get)) { $get = explode(',', $get); } if (!empty($get)) { foreach ($get as $g) { $amenities[] = [ '@type' => 'LocationFeatureSpecification', 'name' => $g, 'value' => true, ]; } if (!empty($amenities)) { $amenities = [ 'amenityFeatures' => $amenities ]; } } return $amenities; } /** * @param array $meta * * @return array */ private function getCredentials(array $meta):array { $health = [ 'hasHealthAspect' => [ '@type' => 'HealthAspect', 'name' => 'Safety Protocols', 'description' => 'Following Alberta Health Services guidelines for tattoo safety and sterilization' ] ]; $credentials = []; $get = $meta['credentials']??[]; if (!is_array($get)) { $get = explode(',', $get); } if (!empty($get)) { foreach ($get as $g) { $credentials[] = [ '@type' => 'EducationalOccupationalCredential', 'name' => $g, 'credentialCategory' => 'Safety Certification', ]; } if (!empty($credentials)) { $credentials = [ 'hasCredential' => $credentials ]; } } return (empty($credentials)) ? $health : array_merge($health, $credentials); } /** * @param array $meta * * @return array */ private function getDissolutionDate(array $meta):array { if (array_key_exists('permanently_close', $meta)) { return [ 'dissolutionDate' => $meta['dissolution_date'] ]; } return []; } /** * @param int $termID * @param string $taxonomy * * @return array */ private function getArtists(int $termID, string $taxonomy):array { $artists = new WP_Query(array( 'post_type' => BASE.'artist', 'posts_per_page' => -1, 'orderby' => 'title', 'tax_query' => [ [ 'taxonomy' => $taxonomy, 'terms' => $termID, ] ], 'fields' => 'ids', )); $out = []; if ($artists->have_posts()) { foreach ($artists->posts as $artist) { $url = get_permalink($artist); $out[] = [ '@type' => 'Person', '@id' => $url, 'name' => get_the_title($artist), 'url' => $url, ]; } } return $out; } /** * Get style schema * @param int $term_id The style term ID * @return array Schema data */ private function getStyleSchema(int $term_id):array { $meta = new MetaManager($term_id, 'term'); $term = get_term($term_id, BASE.'style'); $permalink = get_term_link($term_id, BASE.'style'); $schema = array_merge( [ '@type' => 'CreativeWork', '@id' => $permalink . '#style', 'name' => html_entity_decode($term->name), 'description' => $meta->getValue('characteristics') ?: $term->description, 'url' => $permalink, 'mainEntityOfPage' => [ '@type' => 'WebPage', '@id' => $permalink ] ], $this->getAlternateName($meta), ); $artists = $this->getArtists($term_id, BASE.'style'); if (!empty($artists)) { $schema['mentions'] = $artists; } return $schema; } /** * Get Theme schema * @param int $term_id The theme term ID * @return array Schema data */ private function getThemeSchema(int $term_id):array { $meta = new MetaManager($term_id, 'term'); $term = get_term($term_id, BASE.'theme'); $permalink = get_term_link($term_id, BASE.'theme'); $schema = [ '@type' => 'CreativeWork', '@id' => $permalink . '#theme', 'name' => html_entity_decode($term->name), 'description' => $meta->getValue('description') ?: $term->description, 'url' => $permalink, 'mainEntityOfPage' => [ '@type' => 'WebPage', '@id' => $permalink ] ]; $artists = $this->getArtists($term_id, BASE.'theme'); if (!empty($artists)) { $schema['mentions'] = $artists; } return $schema; } /** * Get Partner schema * @param int $post_id The partner post ID * @return array Schema data */ private function getPartnerSchema(int $post_id):array { $meta = new MetaManager($post_id, 'post'); $metaValues = $meta->getAll(); $permalink = get_permalink($post_id); $schema = array_merge( [ '@type' => 'Organization', '@id' => $permalink . '#organization', 'name' => get_the_title($post_id), 'url' => $permalink, 'description' => get_the_excerpt($post_id), 'memberOf' => [ '@id' => 'https://edmonton.ink/#organization' ] ], $this->getAlternateName($metaValues), $this->getLinks($metaValues), $this->getSlogan($metaValues), $this->getContact($metaValues), $this->getLocation($metaValues), $this->getCities($metaValues), $this->getHours($metaValues), $this->getSpecialties($metaValues), $this->getRate($metaValues), $this->getAwards($metaValues), $this->getRatings($metaValues), $this->getReviews($metaValues), $this->getServices($metaValues), $this->getLanguages($metaValues), $this->getPaymentAccepted($metaValues), $this->getAmenities($metaValues), $this->getCredentials($metaValues), $this->getKeywords($metaValues), $this->getDissolutionDate($metaValues), ); // Add image/logo $image_id = $metaValues['image']; if ($image_id) { $image_url = wp_get_attachment_image_url($image_id, 'full'); if ($image_url) { $schema['logo'] = $image_url; $schema['image'] = $image_url; } } return $schema; } /** * Get Home page schema * @return array Schema data */ private function getHomeSchema():array { // Main dataset schema for homepage return [ '@type' => 'WebPage', '@id' => get_home_url() . '/#webpage', 'url' => get_home_url(), 'name' => get_bloginfo('name') . ' | Edmonton\'s Best Tattoo Artists', 'description' => 'Discover Edmonton\'s top tattoo artists, shops, and styles. Your comprehensive guide to Edmonton\'s tattoo scene.', 'isPartOf' => [ '@id' => get_home_url() . '/#website' ], 'about' => [ '@type' => 'Dataset', 'name' => 'Edmonton Tattoo Artist Directory', 'description' => 'Comprehensive directory of professional tattoo artists in Edmonton, Alberta', 'creator' => [ '@id' => 'https://legacytattooremoval.ca/#organization' ], 'publisher' => [ '@id' => 'https://legacytattooremoval.ca/#organization' ] ], ]; } /** * Adds enhanced schema support for directory pages * * @return array Schema data */ private function getDirectorySchema():array { $current = jvbGetDirectoryInfo(); if (empty($current)){ return []; } if ($current['title'] === 'Map') { return $this->getMapSchema(); } // Base schema for collection page $schema = [ '@type' => 'CollectionPage', '@id' => $current['url'] . '#collectionpage', 'name' => $current['title'] . ' Directory', 'description' => 'An alphabetical directory of '.strtolower($current['title']).'.', 'url' => $current['url'], 'isPartOf' => [ '@id' => get_home_url() . '/#website' ], 'about' => [ '@type' => 'Thing', 'name' => $current['title'], ], 'mainEntity' => [ '@type' => 'ItemList', 'itemListElement' => [] ] ]; $items = []; switch ($current['slug']) { case BASE.'artist': $type = 'Person'; break; case BASE.'event': $type = 'Event'; break; case BASE.'shop': case BASE.'partner': $type = 'LocalBusiness'; break; default: $type = 'Thing'; break; } if ($current['type'] === 'post') { $args = [ 'post_type' => $current['slug'], 'posts_per_page' => 50, 'orderby' => 'title', 'order' => 'ASC' ]; $things = get_posts($args); foreach ($things as $index => $thing) { $items[] = [ '@type' => 'ListItem', 'position' => $index+1, 'item' => [ '@type' => $type, 'name' => $thing->post_title, 'url' => get_permalink($thing->ID) ] ]; } } elseif ($current['type'] === 'term') { $things = get_terms([ 'taxonomy' => $current['slug'], 'hide_empty' => true, 'number' => 50, ]); foreach ($things as $index => $thing) { $items[] = [ '@type' => 'ListItem', 'position' => $index+1, 'item' => [ '@type' => $type, 'name' => $thing->name, 'url' => get_term_link($thing) ] ]; } } $schema['mainEntity']['itemListElement'] = $items; return $schema; } /** * Output schema for map page * * @return array Schema data * TODO: Should we list 50 shops here? */ private function getMapSchema():array { $map = $this->directories[get_the_ID()]; return [ '@type' => 'MapLocation', '@id' => $map['url'] . '#map', 'name' => 'Edmonton Tattoo Shops Map', 'description' => 'Interactive map of tattoo shops in Edmonton', 'url' => $map['url'], 'hasMap' => [ '@type' => 'Map', 'mapType' => 'VenueMap' ] ]; } }