*/ private const DAYS = [ 'Mo' => 'Monday', 'Tu' => 'Tuesday', 'We' => 'Wednesday', 'Th' => 'Thursday', 'Fr' => 'Friday', 'Sa' => 'Saturday', 'Su' => 'Sunday', ]; /** * Organization fields that a `Person` site must not carry. * * A personal site is one human, not a company: `legalName`, `taxID` and an * opening hours table are not properties of a `Person` and would only make * the node fail validation. * * @var array */ private const ORGANIZATION_ONLY = [ 'legalName', 'logo', 'foundingDate', 'taxID', 'geo', 'openingHoursSpecification', 'priceRange', 'areaServed', 'founder' ]; /** * Site facts for the graph being built. * * @var array */ private array $site = []; /** * Page facts for the graph being built. * * @var array */ private array $page = []; /** * Which pieces the settings allow. * * @var array */ private array $pieces = []; /** * Home URL, the base of every site scoped `@id`. * * @var string */ private string $home = ''; /** * Current page URL, the base of every page scoped `@id`. * * @var string */ private string $url = ''; /** * People the graph names, keyed by user ID. * * @var array> */ private array $people = []; /** * Builds the graph. * * @param array $ctx Keys `site`, `page` and `pieces`; `page` is what * `Data\PageData::current()` describes. * @return array `@context` and `@graph`, or an empty array when no * piece produced a node. */ public function build( array $ctx ): array { $this->site = $this->sub_array( $ctx, 'site' ); $this->page = $this->sub_array( $ctx, 'page' ); $this->pieces = $this->sub_array( $ctx, 'pieces' ); $this->home = $this->line( $this->site['home_url'] ?? '' ); $this->url = $this->line( $this->page['url'] ?? '' ); $this->people = $this->people(); $nodes = array_merge( [ $this->organization() ], array_values( $this->people ), [ $this->website(), $this->image(), $this->breadcrumb(), $this->webpage(), $this->article(), $this->faq() ] ); $nodes = array_values( array_filter( $nodes ) ); if ( [] === $nodes ) { return []; } return [ '@context' => 'https://schema.org', '@graph' => $nodes, ]; } /** * Who the site belongs to: `Organization`, one of its subtypes, or `Person`. * * A subtype is emitted as `[ 'Organization', subtype ]` so that a consumer * that only knows the base type still recognizes the node. * * @return array The node, or an empty array when the piece is off. */ private function organization(): array { if ( ! $this->piece( 'organization' ) || '' === $this->home ) { return []; } $is_person = $this->is_person_site(); $logo = $this->text( 'logo_url', $this->site ); $image = $this->text( 'image_url', $this->site ); $node = [ '@type' => $this->organization_type(), '@id' => $this->organization_id(), 'name' => $this->text( 'name', $this->site ), 'alternateName' => $this->text( 'alternate_name', $this->site ), 'legalName' => $this->text( 'legal_name', $this->site ), 'url' => $this->home, 'logo' => '' !== $logo ? [ '@type' => 'ImageObject', 'url' => $logo, ] : [], 'image' => '' !== $image || ! $is_person ? $image : $logo, 'description' => $this->text( 'description', $this->site ), 'email' => $this->text( 'email', $this->site ), 'telephone' => $this->text( 'phone', $this->site ), 'foundingDate' => $this->text( 'founding_date', $this->site ), 'taxID' => $this->text( 'tax_id', $this->site ), 'address' => $this->address(), 'geo' => $this->geo(), 'openingHoursSpecification' => $this->opening_hours(), 'priceRange' => $this->text( 'price_range', $this->site ), 'areaServed' => $this->places( $this->sub_array( $this->site, 'area_served' ) ), 'knowsLanguage' => $this->strings( $this->sub_array( $this->site, 'languages' ) ), 'sameAs' => $this->strings( $this->sub_array( $this->site, 'same_as' ) ), 'founder' => $this->person_ref( (int) ( $this->sub_array( $this->site, 'founder' )['id'] ?? 0 ) ), ]; if ( $is_person ) { foreach ( self::ORGANIZATION_ONLY as $field ) { unset( $node[ $field ] ); } } return $this->prune( $node ); } /** * The site as a searchable website. * * @return array The node, or an empty array when the piece is off. */ private function website(): array { if ( ! $this->piece( 'website' ) || '' === $this->home ) { return []; } return $this->prune( [ '@type' => 'WebSite', '@id' => $this->home . '#website', 'url' => $this->home, 'name' => $this->text( 'name', $this->site ), 'publisher' => $this->organization_ref(), 'inLanguage' => $this->language(), 'potentialAction' => $this->search_action(), ] ); } /** * The site search, as an action an assistant can invoke. * * Left out on the search results page itself, where the query is already * answered and the action would only point back at the same screen. * * @return array The action, or an empty array. */ private function search_action(): array { $search = $this->text( 'search_url', $this->site ); if ( '' === $search || 'search' === $this->kind() ) { return []; } return [ '@type' => 'SearchAction', 'target' => [ '@type' => 'EntryPoint', 'urlTemplate' => $search, ], 'query-input' => 'required name=search_term_string', ]; } /** * The page itself, in whichever `WebPage` flavour it is. * * @return array The node, or an empty array when the piece is off. */ private function webpage(): array { if ( ! $this->piece( 'webpage' ) || '' === $this->url ) { return []; } $type = $this->text( 'webpage_type', $this->page ); return $this->prune( [ '@type' => '' !== $type ? $type : 'WebPage', '@id' => $this->url . '#webpage', 'url' => $this->url, 'name' => $this->text( 'title', $this->page ), 'description' => $this->text( 'description', $this->page ), 'isPartOf' => $this->piece( 'website' ) && '' !== $this->home ? [ '@id' => $this->home . '#website' ] : [], 'about' => 'home' === $this->kind() ? $this->organization_ref() : [], 'primaryImageOfPage' => [] !== $this->image() ? [ '@id' => $this->url . '#primaryimage' ] : [], 'datePublished' => $this->text( 'date_published', $this->page ), 'dateModified' => $this->text( 'date_modified', $this->page ), 'inLanguage' => $this->language(), 'breadcrumb' => [] !== $this->breadcrumb() ? [ '@id' => $this->url . '#breadcrumb' ] : [], 'mainEntity' => $this->main_entity(), ] ); } /** * What the page is chiefly about: its article, or the author it profiles. * * @return array Reference, or an empty array. */ private function main_entity(): array { if ( [] !== $this->article() ) { return [ '@id' => $this->url . '#article' ]; } if ( 'author' === $this->kind() ) { return $this->person_ref( (int) ( $this->sub_array( $this->page, 'author' )['id'] ?? 0 ) ); } return []; } /** * The trail from the home page down to this one. * * A single crumb is the page itself, which is no trail, so the node starts * at two. * * @return array The node, or an empty array. */ private function breadcrumb(): array { $crumbs = $this->sub_array( $this->page, 'breadcrumbs' ); if ( ! $this->piece( 'breadcrumb' ) || '' === $this->url || count( $crumbs ) < 2 ) { return []; } $items = []; $position = 0; foreach ( $crumbs as $crumb ) { $crumb = is_array( $crumb ) ? $crumb : []; $name = $this->text( 'name', $crumb ); if ( '' === $name ) { continue; } ++$position; $items[] = $this->prune( [ '@type' => 'ListItem', 'position' => $position, 'name' => $name, 'item' => $this->text( 'url', $crumb ), ] ); } if ( count( $items ) < 2 ) { return []; } return [ '@type' => 'BreadcrumbList', '@id' => $this->url . '#breadcrumb', 'itemListElement' => $items, ]; } /** * The article this page carries, when its post type maps to one. * * @return array The node, or an empty array. */ private function article(): array { $type = $this->text( 'article_type', $this->page ); if ( ! $this->piece( 'article' ) || '' === $this->url || '' === $type || 'singular' !== $this->kind() ) { return []; } $words = (int) ( $this->page['word_count'] ?? 0 ); $keywords = $this->strings( $this->sub_array( $this->page, 'keywords' ) ); return $this->prune( [ '@type' => $type, '@id' => $this->url . '#article', 'headline' => $this->truncate( $this->text( 'title', $this->page ), self::HEADLINE_LIMIT ), 'description' => $this->text( 'description', $this->page ), 'abstract' => $this->text( 'summary', $this->page ), 'image' => [] !== $this->image() ? [ '@id' => $this->url . '#primaryimage' ] : [], 'author' => $this->person_ref( (int) ( $this->sub_array( $this->page, 'author' )['id'] ?? 0 ) ), 'publisher' => $this->organization_ref(), 'datePublished' => $this->text( 'date_published', $this->page ), 'dateModified' => $this->text( 'date_modified', $this->page ), 'mainEntityOfPage' => $this->piece( 'webpage' ) ? [ '@id' => $this->url . '#webpage' ] : [], 'inLanguage' => $this->language(), 'wordCount' => $words > 0 ? $words : '', 'keywords' => implode( ', ', $keywords ), 'articleSection' => $this->text( 'section', $this->page ), 'isPartOf' => $this->piece( 'website' ) && '' !== $this->home ? [ '@id' => $this->home . '#website' ] : [], 'speakable' => $this->speakable(), ] ); } /** * Which parts of the page a voice assistant should read out. * * The TL;DR block first, then the headline; with no block, only the * headline is worth reading aloud. * * @return array The specification, or an empty array. */ private function speakable(): array { if ( ! $this->piece( 'speakable' ) ) { return []; } $has_block = ! empty( $this->page['has_summary_block'] ); if ( ! $has_block && '' === $this->text( 'title', $this->page ) ) { return []; } return [ '@type' => 'SpeakableSpecification', 'cssSelector' => $has_block ? [ '.acid-aeo-summary', 'h1' ] : [ 'h1' ], ]; } /** * The page's featured image, with its licence when the media library has one. * * Only printed while something points at it: with both the page and the * article pieces off, the node would sit in the graph unreferenced. * * @return array The node, or an empty array. */ private function image(): array { $image = $this->sub_array( $this->page, 'image' ); $url = $this->text( 'url', $image ); if ( '' === $url || '' === $this->url || ( ! $this->piece( 'webpage' ) && ! $this->has_article() ) ) { return []; } $width = (int) ( $image['width'] ?? 0 ); $height = (int) ( $image['height'] ?? 0 ); $meta = $this->piece( 'image_meta' ); $license = $meta ? $this->text( 'license', $image ) : ''; $creator = $meta ? $this->text( 'creator', $image ) : ''; return $this->prune( [ '@type' => 'ImageObject', '@id' => $this->url . '#primaryimage', 'url' => $url, 'width' => $width > 0 ? $width : '', 'height' => $height > 0 ? $height : '', 'caption' => $this->text( 'caption', $image ), 'license' => $license, 'acquireLicensePage' => $license, 'creditText' => $meta ? $this->text( 'credit', $image ) : '', 'creator' => '' !== $creator ? [ '@type' => 'Person', 'name' => $creator, ] : [], ] ); } /** * The questions this page answers. * * @return array The node, or an empty array. */ private function faq(): array { $rows = $this->sub_array( $this->page, 'faq' ); if ( ! $this->piece( 'faq' ) || '' === $this->url || [] === $rows ) { return []; } $questions = []; foreach ( $rows as $row ) { $row = is_array( $row ) ? $row : []; $question = $this->text( 'q', $row ); $answer = $this->text( 'a', $row ); if ( '' === $question || '' === $answer ) { continue; } $questions[] = [ '@type' => 'Question', 'name' => $question, 'acceptedAnswer' => [ '@type' => 'Answer', 'text' => $answer, ], ]; } if ( [] === $questions ) { return []; } return [ '@type' => 'FAQPage', '@id' => $this->url . '#faq', 'mainEntity' => $questions, ]; } /** * The people the graph names, keyed by user ID. * * The founder and the author are very often the same human. Two nodes for * one person is the mistake that makes a graph unusable, so they are merged * into a single `#person-{id}`; what the author profile says wins, because * it is the fuller of the two records. * * The founder is named only while the organization node that points at them * is printed. * * @return array> User ID => person node. */ private function people(): array { if ( ! $this->piece( 'person' ) || '' === $this->home ) { return []; } $candidates = []; if ( $this->piece( 'organization' ) ) { $candidates[] = $this->sub_array( $this->site, 'founder' ); } $candidates[] = $this->sub_array( $this->page, 'author' ); $merged = []; foreach ( $candidates as $candidate ) { $id = (int) ( $candidate['id'] ?? 0 ); if ( $id <= 0 ) { continue; } $merged[ $id ] = isset( $merged[ $id ] ) ? $this->merge_person( $merged[ $id ], $candidate ) : $candidate; } $people = []; foreach ( $merged as $id => $person ) { $people[ $id ] = $this->person( $id, $person ); } return $people; } /** * Copies the values a second record knows over the first. * * @param array $person Record built so far. * @param array $extra Record that wins where it says something. * @return array Merged record. */ private function merge_person( array $person, array $extra ): array { foreach ( $extra as $key => $value ) { if ( null !== $value && '' !== $value && [] !== $value ) { $person[ $key ] = $value; } } return $person; } /** * One person, with the expertise the E-E-A-T fields carry. * * @param int $id User ID. * @param array $person Author or founder record. * @return array The node. */ private function person( int $id, array $person ): array { $alumni = $this->text( 'alumni_of', $person ); return $this->prune( [ '@type' => 'Person', '@id' => $this->home . '#person-' . $id, 'name' => $this->text( 'name', $person ), 'url' => $this->text( 'url', $person ), 'image' => $this->text( 'image', $person ), 'description' => $this->text( 'description', $person ), 'jobTitle' => $this->text( 'job_title', $person ), 'worksFor' => $this->is_person_site() ? [] : $this->organization_ref(), 'sameAs' => $this->strings( $this->sub_array( $person, 'same_as' ) ), 'knowsAbout' => $this->strings( $this->sub_array( $person, 'knows_about' ) ), 'alumniOf' => '' !== $alumni ? [ '@type' => 'Organization', 'name' => $alumni, ] : [], ] ); } /** * The site's postal address. * * @return array `PostalAddress`, or an empty array. */ private function address(): array { $address = $this->sub_array( $this->site, 'address' ); return $this->typed( 'PostalAddress', [ 'streetAddress' => $this->text( 'street', $address ), 'addressLocality' => $this->text( 'city', $address ), 'addressRegion' => $this->text( 'region', $address ), 'postalCode' => $this->text( 'postal', $address ), 'addressCountry' => $this->text( 'country', $address ), ] ); } /** * Where the site's business is. * * `0, 0` is the settings default rather than a place anybody trades from, * so it is read as "not set". * * @return array `GeoCoordinates`, or an empty array. */ private function geo(): array { $geo = $this->sub_array( $this->site, 'geo' ); $lat = (float) ( $geo['lat'] ?? 0 ); $lng = (float) ( $geo['lng'] ?? 0 ); if ( 0.0 === $lat && 0.0 === $lng ) { return []; } return [ '@type' => 'GeoCoordinates', 'latitude' => $lat, 'longitude' => $lng, ]; } /** * The opening hours, one specification per stored line. * * Reads the `Mo-Fr 09:00-18:00` form the settings ask for, with day ranges * and comma separated days. A line that does not parse is left out rather * than published as a guess. * * @return array> `OpeningHoursSpecification` list. */ private function opening_hours(): array { $specs = []; foreach ( $this->sub_array( $this->site, 'opening_hours' ) as $line ) { $line = $this->line( $line ); $matched = []; if ( 1 !== preg_match( '/^([A-Za-z]{2}(?:-[A-Za-z]{2})?(?:,\s*[A-Za-z]{2}(?:-[A-Za-z]{2})?)*)\s+(\d{2}:\d{2})-(\d{2}:\d{2})$/', $line, $matched ) ) { continue; } $days = $this->days( $matched[1] ); if ( [] === $days ) { continue; } $specs[] = [ '@type' => 'OpeningHoursSpecification', 'dayOfWeek' => $days, 'opens' => $matched[2], 'closes' => $matched[3], ]; } return $specs; } /** * Expands a day expression into full day names. * * @param string $expression Days, e.g. `Mo-Fr` or `Sa, Su`. * @return array Day names, in week order within each range. */ private function days( string $expression ): array { $names = array_values( self::DAYS ); $days = []; foreach ( explode( ',', $expression ) as $part ) { $bounds = explode( '-', trim( $part ) ); $from = $this->day_index( $bounds[0] ?? '' ); $to = $this->day_index( $bounds[1] ?? $bounds[0] ?? '' ); if ( null === $from || null === $to ) { continue; } $span = ( $to - $from + 7 ) % 7; for ( $step = 0; $step <= $span; $step++ ) { $days[] = $names[ ( $from + $step ) % 7 ]; } } return array_values( array_unique( $days ) ); } /** * Position of a two letter day abbreviation in the week. * * @param string $day Abbreviation, e.g. `mo`. * @return int|null Index from 0 (Monday), or null when unknown. */ private function day_index( string $day ): ?int { $day = ucfirst( strtolower( trim( $day ) ) ); $index = array_search( $day, array_keys( self::DAYS ), true ); return false === $index ? null : (int) $index; } /** * A list of names as `Place` nodes. * * @param array $values Place names. * @return array> Places. */ private function places( array $values ): array { $places = []; foreach ( $this->strings( $values ) as $name ) { $places[] = [ '@type' => 'Place', 'name' => $name, ]; } return $places; } /** * A reference to the organization node, while it is printed. * * @return array `@id` reference, or an empty array. */ private function organization_ref(): array { if ( ! $this->piece( 'organization' ) || '' === $this->home ) { return []; } return [ '@id' => $this->organization_id() ]; } /** * A reference to a person node, while that person is printed. * * @param int $id User ID. * @return array `@id` reference, or an empty array. */ private function person_ref( int $id ): array { if ( $id <= 0 || ! isset( $this->people[ $id ] ) ) { return []; } return [ '@id' => $this->home . '#person-' . $id ]; } /** * The `@id` of the node that stands for the site owner. * * @return string Absolute `@id`. */ private function organization_id(): string { return $this->home . ( $this->is_person_site() ? '#person' : '#organization' ); } /** * The `@type` of the node that stands for the site owner. * * @return string|array One type, or the base type plus its subtype. */ private function organization_type() { $type = $this->schema_type(); if ( 'Person' === $type ) { return 'Person'; } if ( '' === $type || 'Organization' === $type ) { return 'Organization'; } return [ 'Organization', $type ]; } /** * The schema.org type the site owner chose. * * @return string Type name, e.g. `ProfessionalService`. */ private function schema_type(): string { return $this->text( 'schema_type', $this->site ); } /** * Whether the site stands for one human rather than an organization. * * @return bool True for a `Person` site. */ private function is_person_site(): bool { return 'Person' === $this->schema_type(); } /** * Whether the article node is printed. * * @return bool True when there is an article. */ private function has_article(): bool { return $this->piece( 'article' ) && 'singular' === $this->kind() && '' !== $this->text( 'article_type', $this->page ); } /** * What kind of page this is. * * @return string `home`, `singular`, `archive`, `author`, `search` or `other`. */ private function kind(): string { return $this->text( 'kind', $this->page ); } /** * The language the page is written in. * * @return string Language code, e.g. `ro`. */ private function language(): string { $language = $this->text( 'language', $this->page ); if ( '' !== $language ) { return $language; } $languages = $this->strings( $this->sub_array( $this->site, 'languages' ) ); return (string) ( $languages[0] ?? '' ); } /** * Whether a schema piece is switched on. * * A piece the caller did not mention is on: the graph is described by what * the settings switch off. * * @param string $name Piece name, e.g. `article`. * @return bool True when the piece may be printed. */ private function piece( string $name ): bool { return ! array_key_exists( $name, $this->pieces ) || (bool) $this->pieces[ $name ]; } /** * A typed sub node, dropped when it carries nothing but its type. * * @param string $type Schema.org type. * @param array $fields Node fields. * @return array The node, or an empty array. */ private function typed( string $type, array $fields ): array { $fields = $this->prune( $fields ); if ( [] === $fields ) { return []; } return array_merge( [ '@type' => $type ], $fields ); } /** * Shortens a value to a maximum length, without cutting bytes in half. * * @param string $value Raw value. * @param int $limit Maximum number of characters. * @return string The value, shortened when it was too long. */ private function truncate( string $value, int $limit ): string { if ( function_exists( 'mb_substr' ) ) { return mb_strlen( $value ) > $limit ? rtrim( mb_substr( $value, 0, $limit ) ) : $value; } return strlen( $value ) > $limit ? rtrim( substr( $value, 0, $limit ) ) : $value; } /** * Drops every empty value, however deeply nested. * * Empty means null, an empty string or an empty array. A zero and a `false` * stay: they are answers, not missing answers. Lists are re-indexed so that * a pruned list still encodes as a JSON array. * * @param array $data Raw node. * @return array Node without its empty values. */ private function prune( array $data ): array { $clean = []; foreach ( $data as $key => $value ) { if ( is_array( $value ) ) { $value = $this->prune( $value ); } if ( null === $value || '' === $value || [] === $value ) { continue; } $clean[ $key ] = $value; } return $this->is_list( $data ) ? array_values( $clean ) : $clean; } /** * Whether an array is a list, and so must stay one after pruning. * * `array_is_list()` is PHP 8.1; the plugin supports 8.0. * * @param array $data Array to test. * @return bool True when the keys are `0..n`. */ private function is_list( array $data ): bool { return [] === $data || array_keys( $data ) === range( 0, count( $data ) - 1 ); } /** * A sub array of an array, whatever the caller passed. * * @param array $data Input array. * @param string $key Key to read. * @return array The value when it is an array, else an empty array. */ private function sub_array( array $data, string $key ): array { return is_array( $data[ $key ] ?? null ) ? $data[ $key ] : []; } /** * One field of an array as a single trimmed line. * * @param string $key Key to read. * @param array $data Input array. * @return string Line, empty when the field is missing or not text. */ private function text( string $key, array $data ): string { return $this->line( $data[ $key ] ?? '' ); } /** * A value as a list of non-empty strings. * * @param array $values Raw values. * @return array Items. */ private function strings( array $values ): array { $items = []; foreach ( $values as $value ) { $value = $this->line( $value ); if ( '' !== $value ) { $items[] = $value; } } return $items; } /** * A value as a single trimmed line. * * @param mixed $value Raw value. * @return string Line, empty when the value is not text. */ private function line( $value ): string { if ( ! is_scalar( $value ) ) { return ''; } return trim( (string) preg_replace( '/\s+/', ' ', (string) $value ) ); } }