'ai-context.json' ] ); add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); add_action( 'save_post', [ $this, 'invalidate_post' ], 10, 2 ); } /** * Renders `/ai-context.json`. * * @return string File body. */ public function render(): string { $json = wp_json_encode( ( new AiContextGenerator() )->generate( $this->data() ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return is_string( $json ) ? $json . "\n" : "{}\n"; } /** * Audit checks contributed by this module. * * A file that is served but does not parse is worse than one that is * missing: the program reading it has no fallback, so the body is decoded * here rather than only counted. * * @return array Checks. */ public function checks(): array { return [ Checks::reachable( 'ai_context_reachable', $this->slug(), __( 'ai-context.json is reachable', 'acid-aeo' ), Router::url( 'ai_context' ), [ 'type' => 'application/json', 'weight' => Check::WEIGHT_IMPORTANT, 'fix' => 'flush_rewrite', 'test' => static fn( string $body ): bool => is_array( json_decode( $body, true ) ), 'test_message' => __( 'ai-context.json is served but is not valid JSON, so nothing can load it. Another plugin is most likely printing into the response.', 'acid-aeo' ), ] ), Checks::physical_conflict( 'ai_context_physical_conflict', $this->slug(), 'ai-context.json', 'ai-context.json' ), ]; } /** * Drops every cached file after a change that could alter them. * * @return void */ public function invalidate(): void { Cache::invalidate_all(); } /** * Drops the cache after a post changed, ignoring the noise around a save. * * The key pages and their FAQs are read from posts, so a published post can * change this file; an autosave, a revision and a post type nobody can read * cannot, and each of them would cost a cache version bump on every * keystroke the editor sends. * * @param mixed $post_id Post ID. * @param mixed $post Post object, as `save_post` passes it. * @return void */ public function invalidate_post( $post_id, $post = null ): void { if ( $this->is_listed_post( (int) $post_id, $post ) ) { $this->invalidate(); } } /** * The site profile the document is rendered from. * * @return array Input for `Generators\AiContextGenerator`. */ public function data(): array { $identity = SiteData::identity(); $policy = SiteData::policy(); $data = [ 'version' => AiContextGenerator::VERSION, 'generated' => gmdate( 'Y-m-d' ), 'language' => (string) ( $identity['language'] ?? '' ), 'generator_version' => ACID_AEO_VERSION, 'organization' => $this->organization( $identity ), 'offerings' => $this->offerings(), 'key_pages' => $this->key_pages(), 'faq' => $this->faq(), 'contact' => [ 'email' => (string) ( $identity['email'] ?? '' ), 'telephone' => (string) ( $identity['phone'] ?? '' ), 'address' => $this->address_line( $identity ), 'openingHours' => $this->strings( $identity['opening_hours'] ?? [] ), ], 'policy' => [ 'preset' => (string) ( $policy['preset'] ?? '' ), 'content_signal' => SiteData::content_signal_string(), 'license' => (string) ( $policy['license_label'] ?? '' ), 'license_url' => (string) ( $policy['license_url'] ?? '' ), 'permissions_contact' => (string) ( $policy['permissions_contact'] ?? '' ), ], 'links' => $this->links(), ]; /** * Filters the site profile `ai-context.json` is built from. * * @param array $data Site profile, as described by `Modules\AiContext::data()`. */ $data = apply_filters( 'acid_aeo_ai_context_data', $data ); return is_array( $data ) ? $data : []; } /** * Who the site belongs to, in the vocabulary of the document. * * The generator drops whatever is empty, so every field is offered here and * only the ones the site owner filled in are published. * * @param array $identity Identity from `Data\SiteData`. * @return array Organization section. */ private function organization( array $identity ): array { return [ 'name' => (string) ( $identity['name'] ?? '' ), 'legalName' => (string) ( $identity['legal_name'] ?? '' ), 'alternateName' => (string) ( $identity['alternate_name'] ?? '' ), 'url' => (string) ( $identity['home_url'] ?? '' ), 'logo' => (string) ( $identity['logo_url'] ?? '' ), 'description' => (string) ( $identity['description'] ?? '' ), 'email' => (string) ( $identity['email'] ?? '' ), 'telephone' => (string) ( $identity['phone'] ?? '' ), 'foundingDate' => (string) ( $identity['founding_date'] ?? '' ), 'taxID' => (string) ( $identity['tax_id'] ?? '' ), 'address' => $this->address( $identity ), 'geo' => $this->geo( $identity ), 'openingHours' => $this->strings( $identity['opening_hours'] ?? [] ), 'priceRange' => (string) ( $identity['price_range'] ?? '' ), 'areaServed' => $this->strings( $identity['area_served'] ?? [] ), 'knowsLanguage' => $this->strings( $identity['languages'] ?? [] ), 'sameAs' => $this->strings( $identity['same_as'] ?? [] ), 'founder' => $this->founder( $identity ), ]; } /** * The postal address, field by field. * * @param array $identity Identity from `Data\SiteData`. * @return array Address section. */ private function address( array $identity ): array { $address = is_array( $identity['address'] ?? null ) ? $identity['address'] : []; return [ 'streetAddress' => (string) ( $address['street'] ?? '' ), 'addressLocality' => (string) ( $address['city'] ?? '' ), 'addressRegion' => (string) ( $address['region'] ?? '' ), 'postalCode' => (string) ( $address['postal'] ?? '' ), 'addressCountry' => (string) ( $address['country'] ?? '' ), ]; } /** * The postal address on one line, for the contact section. * * @param array $identity Identity from `Data\SiteData`. * @return string Address, empty when none is set. */ private function address_line( array $identity ): string { $address = is_array( $identity['address'] ?? null ) ? $identity['address'] : []; $parts = []; foreach ( [ 'street', 'postal', 'city', 'region', 'country' ] as $key ) { $value = trim( (string) ( $address[ $key ] ?? '' ) ); if ( '' !== $value ) { $parts[] = $value; } } return implode( ', ', $parts ); } /** * Where the site's business is, when its coordinates are set. * * `0, 0` is the settings default rather than a place anybody trades from, * so it is read as "not set" and the whole section is left out. * * @param array $identity Identity from `Data\SiteData`. * @return array Geo section, empty when there are no coordinates. */ private function geo( array $identity ): array { $geo = is_array( $identity['geo'] ?? null ) ? $identity['geo'] : []; $lat = (float) ( $geo['lat'] ?? 0 ); $lng = (float) ( $geo['lng'] ?? 0 ); if ( 0.0 === $lat && 0.0 === $lng ) { return []; } return [ 'latitude' => $lat, 'longitude' => $lng, ]; } /** * Who founded the site's organization. * * @param array $identity Identity from `Data\SiteData`. * @return array Founder section, empty when none is named. */ private function founder( array $identity ): array { $founder = is_array( $identity['founder'] ?? null ) ? $identity['founder'] : []; if ( [] === $founder ) { return []; } return [ 'name' => (string) ( $founder['name'] ?? '' ), 'url' => (string) ( $founder['url'] ?? '' ), ]; } /** * Turns stored offering rows into the vocabulary of the document. * * A price of zero is the settings default, not a free service, so it is left * out entirely rather than published as `"priceFrom": 0` — which a reader * would quote back as a price. Static and free of WordPress calls so the * mapping can be tested on its own. * * @param array $rows Rows from the `offerings` setting. * @return array> Offering rows. */ public static function map_offerings( array $rows ): array { $offerings = []; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $offering = [ 'name' => (string) ( $row['name'] ?? '' ), 'url' => (string) ( $row['url'] ?? '' ), 'summary' => (string) ( $row['summary'] ?? '' ), ]; $price = (float) ( $row['price_from'] ?? 0 ); if ( $price > 0 ) { $offering['priceFrom'] = $price; } $offering['currency'] = (string) ( $row['currency'] ?? '' ); $offering['delivery'] = (string) ( $row['delivery'] ?? '' ); $offerings[] = $offering; } return $offerings; } /** * What the site sells. * * @return array> Offering rows. */ private function offerings(): array { $rows = Options::get( 'offerings', [] ); return self::map_offerings( is_array( $rows ) ? $rows : [] ); } /** * The pages a reader should start from. * * @return array> Rows of `title`, `url` and `summary`. */ private function key_pages(): array { $pages = []; foreach ( ContentData::key_pages() as $page ) { $pages[] = [ 'title' => (string) ( $page['title'] ?? '' ), 'url' => (string) ( $page['url'] ?? '' ), 'summary' => (string) ( $page['summary'] ?? '' ), ]; } return $pages; } /** * The site wide FAQ followed by the FAQ of every key page. * * @return array> Rows of `question` and `answer`. */ private function faq(): array { $rows = ContentData::global_faq(); foreach ( ContentData::key_pages() as $page ) { $rows = array_merge( $rows, ContentData::faq_for( (int) ( $page['id'] ?? 0 ) ) ); } $faq = []; foreach ( $rows as $row ) { $faq[] = [ 'question' => (string) ( $row['q'] ?? '' ), 'answer' => (string) ( $row['a'] ?? '' ), ]; } return $faq; } /** * Where the rest of the site's machine readable files live. * * A file this plugin publishes is linked only while it is registered, so a * switched off module leaves no dead link behind. The sitemap and the feed * come from WordPress and are always there. * * @return array Links section. */ private function links(): array { $files = SiteData::file_urls(); return [ 'llms' => $this->published_url( 'llms', $files ), 'llmsFull' => $this->published_url( 'llms_full', $files ), 'humans' => $this->published_url( 'humans', $files ), 'security' => $this->published_url( 'security', $files ), 'sitemap' => (string) ( $files['sitemap'] ?? '' ), 'feed' => (string) ( $files['feed'] ?? '' ), 'aiPolicy' => $this->published_url( 'ai_txt', $files ), ]; } /** * The URL of a file this plugin publishes, while it is published. * * @param string $slug Router slug, e.g. `humans`. * @param array $files URLs from `Data\SiteData::file_urls()`. * @return string Absolute URL, or an empty string when the file is not registered. */ private function published_url( string $slug, array $files ): string { return null !== Router::file( $slug ) ? (string) ( $files[ $slug ] ?? '' ) : ''; } /** * A value as a list of non-empty strings. * * @param mixed $value Raw value. * @return array Items. */ private function strings( $value ): array { $items = []; foreach ( is_array( $value ) ? $value : [] as $item ) { $item = is_scalar( $item ) ? trim( (string) $item ) : ''; if ( '' !== $item ) { $items[] = $item; } } return $items; } }