(or ?id=) * POST /wp-json/agentsia/v1/seo-meta/ * { "title": "...", "description": "...", "noindex": true|false|"default", "canonical": "https://...", * "schema_jsonld": "{\"@context\":\"https://schema.org\", ...}" } * * Authenticated with the Application Password the site owner gave Agentsia. Writing requires the * `edit_post` capability on that very post. A field left out (or null) is not touched. An empty string * (title, description, canonical) or "default" (noindex) removes the custom value, so the SEO plugin * falls back to its own settings. * * The POST response is a fresh read of the stored values: the backend compares it with what it asked * for and never reports a change that did not land. * * Without any SEO plugin, the values are stored by this plugin and printed by it (``, * `<meta name="description">`, robots and canonical), so the agent works on a bare WordPress too. * * `schema_jsonld` (structured data of an article written by the agent) is always stored and printed by this * plugin in `<head>`, whatever the SEO plugin. It cannot live in the post content: the dedicated Agentsia user * has no `unfiltered_html`, so WordPress strips the `<script>` tag and shows the JSON as text. */ final class Seo_Meta { const REST_NAMESPACE = 'agentsia/v1'; const REST_ROUTE = '/seo-meta'; /** Fallback storage when no SEO plugin is active (printed by this plugin). */ private const META_TITLE = '_agentsia_agents_seo_title'; private const META_DESCRIPTION = '_agentsia_agents_seo_description'; private const META_NOINDEX = '_agentsia_agents_seo_noindex'; private const META_CANONICAL = '_agentsia_agents_seo_canonical'; /** Structured data (JSON-LD) printed in <head> by this plugin, whatever the SEO plugin. */ private const META_SCHEMA_JSONLD = '_agentsia_agents_schema_jsonld'; /** Hard limits: far above what search engines show, they only reject obvious mistakes. */ private const MAX_TITLE_LENGTH = 200; private const MAX_DESCRIPTION_LENGTH = 500; private const MAX_URL_LENGTH = 2000; private const MAX_SCHEMA_LENGTH = 50000; /** Post meta keys per SEO plugin: title, description, canonical. AIOSEO keeps them in its own table. */ private const META_KEYS = [ 'yoast' => [ '_yoast_wpseo_title', '_yoast_wpseo_metadesc', '_yoast_wpseo_canonical' ], 'rank_math' => [ 'rank_math_title', 'rank_math_description', 'rank_math_canonical_url' ], 'seopress' => [ '_seopress_titles_title', '_seopress_titles_desc', '_seopress_robots_canonical' ], 'the_seo_framework' => [ '_genesis_title', '_genesis_description', '_genesis_canonical_uri' ], 'agentsia' => [ self::META_TITLE, self::META_DESCRIPTION, self::META_CANONICAL ], ]; private const PROVIDER_LABELS = [ 'yoast' => 'Yoast SEO', 'rank_math' => 'Rank Math', 'seopress' => 'SEOPress', 'aioseo' => 'All in One SEO', 'the_seo_framework' => 'The SEO Framework', 'agentsia_theme' => 'Thème Agentsia', 'agentsia' => 'Agentsia (no SEO plugin detected)', ]; /** * The agentsia.fr theme handles SEO itself (`agentsia_output_seo_meta_tags()` prints description, robots, * canonical and Open Graph from these post meta): writing anywhere else would print a SECOND description. * It has no custom canonical (always the permalink) and a single noindex box. */ private const AGENTSIA_THEME_FIELDS = [ 'title' => '_agentsia_seo_title', 'description' => '_agentsia_seo_description', 'noindex' => '_agentsia_seo_noindex', 'canonical' => null, ]; private static ?self $instance = null; public static function instance(): self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } public function register(): void { add_action( 'rest_api_init', [ $this, 'register_routes' ] ); // Front-end output, only used when no SEO plugin handles it (checked inside each callback). add_filter( 'pre_get_document_title', [ $this, 'filter_document_title' ], 20 ); add_action( 'wp_head', [ $this, 'print_meta_description' ], 1 ); add_action( 'wp_head', [ $this, 'print_schema_jsonld' ], 20 ); add_filter( 'wp_robots', [ $this, 'filter_robots' ], 20 ); add_filter( 'get_canonical_url', [ $this, 'filter_canonical' ], 20, 2 ); add_action( 'init', [ $this, 'migrate_fallback_values' ], 20 ); } /** * Values written while the Agentsia theme was not recognised live in this plugin's fallback fields. Once it * is, they are moved to the theme's fields, once: otherwise the page would go back to its OLD description, * the fallback output being off. */ public function migrate_fallback_values(): void { $provider = self::detect_provider(); if ( $provider !== 'agentsia_theme' || get_option( 'agentsia_seo_fallback_migrated' ) === $provider ) { return; } $fields = self::fields_of( $provider ); $ids = get_posts( [ 'post_type' => 'any', 'post_status' => 'any', 'posts_per_page' => 500, 'fields' => 'ids', 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- runs once. 'relation' => 'OR', [ 'key' => self::META_TITLE, 'compare' => 'EXISTS' ], [ 'key' => self::META_DESCRIPTION, 'compare' => 'EXISTS' ], [ 'key' => self::META_NOINDEX, 'compare' => 'EXISTS' ], ], ] ); foreach ( $ids as $post_id ) { foreach ( [ self::META_TITLE => $fields['title'], self::META_DESCRIPTION => $fields['description'] ] as $from => $to ) { $value = self::nullable_string( get_post_meta( $post_id, $from, true ) ); if ( $value !== null ) { update_post_meta( $post_id, $to, wp_slash( $value ) ); } delete_post_meta( $post_id, $from ); } if ( $fields['noindex'] !== null && get_post_meta( $post_id, self::META_NOINDEX, true ) === '1' ) { update_post_meta( $post_id, $fields['noindex'], '1' ); } delete_post_meta( $post_id, self::META_NOINDEX ); } update_option( 'agentsia_seo_fallback_migrated', $provider, true ); } public function register_routes(): void { register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE, [ 'methods' => 'GET', 'callback' => [ $this, 'handle_read' ], // The post is only known once the URL is resolved: `edit_post` on it is checked in the callback. 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, 'args' => [ 'url' => [ 'type' => 'string' ], 'id' => [ 'type' => 'integer' ], ], ] ); register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE . '/(?P<id>\d+)', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_write' ], 'permission_callback' => static function ( \WP_REST_Request $request ) { return current_user_can( 'edit_post', (int) $request['id'] ); }, 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], ], ] ); } /** * @return \WP_REST_Response|\WP_Error */ public function handle_read( \WP_REST_Request $request ) { $post_id = self::resolve_post_id( $request ); if ( is_wp_error( $post_id ) ) { return $post_id; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'agentsia_seo_meta_forbidden', __( 'Ce compte ne peut pas modifier cette page.', 'agentsia-agents' ), [ 'status' => 403 ] ); } return new \WP_REST_Response( $this->snapshot( $post_id ), 200 ); } /** * @return \WP_REST_Response|\WP_Error */ public function handle_write( \WP_REST_Request $request ) { $post_id = (int) $request['id']; $post = get_post( $post_id ); if ( ! $post || ! is_post_type_viewable( $post->post_type ) || $post->post_type === 'attachment' ) { return new \WP_Error( 'agentsia_seo_meta_not_found', __( 'Page introuvable.', 'agentsia-agents' ), [ 'status' => 404 ] ); } $params = $request->get_json_params(); $params = is_array( $params ) ? $params : []; $title = self::read_text_field( $params, 'title', self::MAX_TITLE_LENGTH ); $description = self::read_text_field( $params, 'description', self::MAX_DESCRIPTION_LENGTH ); $canonical = $this->read_canonical( $params ); $noindex = $this->read_noindex( $params ); $schema = self::read_schema_jsonld( $params ); foreach ( [ $title, $description, $canonical, $noindex, $schema ] as $field ) { if ( is_wp_error( $field ) ) { return $field; } } if ( $title === null && $description === null && $canonical === null && $noindex === null && $schema === null ) { return new \WP_Error( 'agentsia_seo_meta_empty', __( 'Aucun champ à modifier (title, description, noindex, canonical ou schema_jsonld).', 'agentsia-agents' ), [ 'status' => 400 ] ); } $has_seo_fields = $title !== null || $description !== null || $canonical !== null || $noindex !== null; // Defence in depth (the backend refuses it first): a noindex on the home page takes the whole site's // entry point out of search results. if ( $noindex === 'noindex' && self::is_front_page_id( $post_id ) ) { return new \WP_Error( 'agentsia_seo_meta_front_page_noindex', __( 'Refusé : noindex sur la page d\'accueil.', 'agentsia-agents' ), [ 'status' => 422 ] ); } $provider = self::detect_provider(); if ( ! $has_seo_fields ) { // Structured data only: the SEO plugin's fields are not touched. } elseif ( $provider === 'aioseo' ) { $written = $this->write_aioseo( $post_id, $title, $description, $canonical, $noindex ); if ( is_wp_error( $written ) ) { return $written; } } else { $fields = self::fields_of( $provider ); // Checked BEFORE any write: a request is applied entirely or not at all. if ( $canonical !== null && $fields['canonical'] === null ) { return new \WP_Error( 'agentsia_seo_meta_provider_unsupported', sprintf( /* translators: %s: SEO handler. */ __( '%s ne gère pas d\'URL canonique personnalisée.', 'agentsia-agents' ), self::provider_label( $provider ) ), [ 'status' => 501 ] ); } if ( in_array( $noindex, [ 'index', 'noindex' ], true ) && $provider === 'agentsia_theme' && $fields['noindex'] === null ) { return new \WP_Error( 'agentsia_seo_meta_provider_unsupported', sprintf( /* translators: %s: SEO handler. */ __( '%s ne gère pas le noindex par page.', 'agentsia-agents' ), self::provider_label( $provider ) ), [ 'status' => 501 ] ); } self::write_meta( $post_id, $fields['title'], $title ); self::write_meta( $post_id, $fields['description'], $description ); if ( $fields['canonical'] !== null ) { self::write_meta( $post_id, $fields['canonical'], $canonical ); } // The SEO Framework appends the site name to a custom title unless this per-post box is ticked: // the agent writes the full title, so it is shown exactly as written. if ( $provider === 'the_seo_framework' && $title !== null ) { self::write_meta( $post_id, '_tsf_title_no_blogname', $title === '' ? '' : '1' ); } if ( $noindex !== null ) { $this->write_noindex( $provider, $post_id, $noindex ); } } // Re-encoded JSON (see read_schema_jsonld): stored as is, printed by print_schema_jsonld(). self::write_meta( $post_id, self::META_SCHEMA_JSONLD, $schema ); clean_post_cache( $post_id ); return new \WP_REST_Response( $this->snapshot( $post_id ), 200 ); } /** * Active SEO plugin, in a fixed order when several are active (the first one prints the tags). */ public static function detect_provider(): string { if ( defined( 'WPSEO_VERSION' ) ) { return 'yoast'; } if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ) { return 'rank_math'; } if ( defined( 'SEOPRESS_VERSION' ) ) { return 'seopress'; } if ( defined( 'AIOSEO_VERSION' ) || function_exists( 'aioseo' ) ) { return 'aioseo'; } if ( defined( 'THE_SEO_FRAMEWORK_VERSION' ) ) { return 'the_seo_framework'; } if ( self::is_agentsia_theme() ) { return 'agentsia_theme'; } return 'agentsia'; } /** * Only Agentsia's OWN theme (« Agentsia Block », text domain `agentsia`) has its SEO fields written: any * other theme keeps the plugin's fallback fields. Both the theme identity and its SEO function are required. */ private static function is_agentsia_theme(): bool { if ( ! function_exists( 'agentsia_output_seo_meta_tags' ) ) { return false; } $theme = wp_get_theme( get_template() ); return $theme->get( 'TextDomain' ) === 'agentsia' || $theme->get( 'Name' ) === 'Agentsia Block'; } /** * Post meta keys used by a provider (AIOSEO excluded: own table). `noindex` here = a single box ('1'). * * @return array{title:string,description:string,canonical:?string,noindex:?string} */ private static function fields_of( string $provider ): array { if ( $provider === 'agentsia_theme' ) { return self::AGENTSIA_THEME_FIELDS; } [ $title, $description, $canonical ] = self::META_KEYS[ $provider ]; return [ 'title' => $title, 'description' => $description, 'canonical' => $canonical, 'noindex' => null ]; } public static function provider_label( string $provider ): string { return self::PROVIDER_LABELS[ $provider ] ?? $provider; } /** * Clean text: tags stripped, spaces folded. `null` = field absent or null (left untouched), '' = reset. * Not sanitize_text_field(): it strips "%xx" sequences, and "-20%de remise" or Yoast's "%%sep%%" are legitimate. * * @return string|null|\WP_Error */ public static function read_text_field( array $params, string $name, int $max_length ) { if ( ! array_key_exists( $name, $params ) || $params[ $name ] === null ) { return null; } if ( ! is_string( $params[ $name ] ) ) { return new \WP_Error( 'agentsia_seo_meta_invalid', __( 'Valeur invalide.', 'agentsia-agents' ), [ 'status' => 400, 'field' => $name ] ); } $value = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( wp_check_invalid_utf8( $params[ $name ] ) ) ) ); if ( mb_strlen( $value ) > $max_length ) { return new \WP_Error( 'agentsia_seo_meta_too_long', /* translators: 1: field name, 2: maximum number of characters. */ sprintf( __( '%1$s trop long (%2$d caractères maximum).', 'agentsia-agents' ), $name, $max_length ), [ 'status' => 400, 'field' => $name ] ); } return $value; } /** * Absolute http(s) URL, or '' to reset. * * @return string|null|\WP_Error */ private function read_canonical( array $params ) { $value = self::read_text_field( $params, 'canonical', self::MAX_URL_LENGTH ); if ( $value === null || $value === '' || is_wp_error( $value ) ) { return $value; } if ( ! preg_match( '#^https?://[^\s/]+#i', $value ) || esc_url_raw( $value, [ 'http', 'https' ] ) !== $value ) { return new \WP_Error( 'agentsia_seo_meta_invalid', __( 'URL canonique invalide (adresse complète http(s) attendue).', 'agentsia-agents' ), [ 'status' => 400, 'field' => 'canonical' ] ); } return $value; } /** * JSON-LD of the page: a JSON object (or array of objects) given as a string. It is decoded then RE-ENCODED * here with `<`, `>` and `&` escaped, so the stored value can never close the `<script>` tag it is printed * in. '' removes it, absent or null leaves it untouched. * * @return string|null|\WP_Error */ public static function read_schema_jsonld( array $params ) { if ( ! array_key_exists( 'schema_jsonld', $params ) || $params['schema_jsonld'] === null ) { return null; } $raw = $params['schema_jsonld']; if ( ! is_string( $raw ) ) { return new \WP_Error( 'agentsia_seo_meta_invalid', __( 'schema_jsonld attend une chaîne JSON.', 'agentsia-agents' ), [ 'status' => 400, 'field' => 'schema_jsonld' ] ); } $raw = trim( wp_check_invalid_utf8( $raw ) ); if ( $raw === '' ) { return ''; } if ( strlen( $raw ) > self::MAX_SCHEMA_LENGTH ) { return new \WP_Error( 'agentsia_seo_meta_too_long', /* translators: 1: field name, 2: maximum number of characters. */ sprintf( __( '%1$s trop long (%2$d caractères maximum).', 'agentsia-agents' ), 'schema_jsonld', self::MAX_SCHEMA_LENGTH ), [ 'status' => 400, 'field' => 'schema_jsonld' ] ); } $data = json_decode( $raw, true ); if ( ! is_array( $data ) || $data === [] ) { return new \WP_Error( 'agentsia_seo_meta_invalid', __( 'schema_jsonld n\'est pas un objet JSON valide.', 'agentsia-agents' ), [ 'status' => 400, 'field' => 'schema_jsonld' ] ); } $encoded = wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP ); if ( ! is_string( $encoded ) ) { return new \WP_Error( 'agentsia_seo_meta_invalid', __( 'schema_jsonld n\'est pas un objet JSON valide.', 'agentsia-agents' ), [ 'status' => 400, 'field' => 'schema_jsonld' ] ); } return $encoded; } /** * Prints the structured data written by the agent on the page it belongs to. The stored value was re-encoded * with `<`, `>` and `&` escaped (read_schema_jsonld): it cannot break out of the tag. */ public function print_schema_jsonld(): void { if ( ! is_singular() ) { return; } $json = self::nullable_string( get_post_meta( (int) get_queried_object_id(), self::META_SCHEMA_JSONLD, true ) ); if ( $json === null ) { return; } $data = json_decode( $json, true ); if ( is_array( $data ) ) { $completed = self::with_site_publisher( $data ); if ( $completed !== $data ) { $encoded = wp_json_encode( $completed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP ); if ( is_string( $encoded ) ) { $json = $encoded; } } } echo '<script type="application/ld+json">' . $json . '</script>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON encoded with JSON_HEX_TAG | JSON_HEX_AMP, on write and above. } /** * Completes the `publisher` of the page with the site's address: an `Organization` given by its name only * (what the agent writes in an article) gets the `url` of the home page and the `@id` of the Organization * block of the home page (`<home>/#organization`, see Site_Identity), so readers tie the article to the * company described there. A publisher that already has a `url` or an `@id` is left as written: it may be * another site. Applies to the root object, to a list of objects and to the nodes of an `@graph`. */ private static function with_site_publisher( array $data ): array { if ( isset( $data['@graph'] ) && is_array( $data['@graph'] ) ) { $data['@graph'] = self::with_site_publisher( $data['@graph'] ); } if ( $data !== [] && array_keys( $data ) === range( 0, count( $data ) - 1 ) ) { foreach ( $data as $i => $node ) { if ( is_array( $node ) ) { $data[ $i ] = self::with_site_publisher( $node ); } } return $data; } $publisher = $data['publisher'] ?? null; if ( ! is_array( $publisher ) || ( $publisher['@type'] ?? null ) !== 'Organization' || isset( $publisher['url'] ) || isset( $publisher['@id'] ) ) { return $data; } $data['publisher'] = array_merge( $publisher, [ 'url' => home_url( '/' ), '@id' => home_url( '/#organization' ), ] ); return $data; } /** * true → 'noindex', false → 'index' (forced), "default" → 'default' (plugin settings), absent → null. * * @return string|null|\WP_Error */ private function read_noindex( array $params ) { if ( ! array_key_exists( 'noindex', $params ) || $params['noindex'] === null ) { return null; } $value = $params['noindex']; if ( $value === true ) { return 'noindex'; } if ( $value === false ) { return 'index'; } if ( $value === 'default' ) { return 'default'; } return new \WP_Error( 'agentsia_seo_meta_invalid', __( 'noindex attend true, false ou "default".', 'agentsia-agents' ), [ 'status' => 400, 'field' => 'noindex' ] ); } private static function write_meta( int $post_id, string $key, ?string $value ): void { if ( $value === null ) { return; } if ( $value === '' ) { delete_post_meta( $post_id, $key ); return; } update_post_meta( $post_id, $key, wp_slash( $value ) ); } /** * @param string $value 'noindex' | 'index' | 'default' */ private function write_noindex( string $provider, int $post_id, string $value ): void { switch ( $provider ) { case 'yoast': // '1' = noindex, '2' = index, absent = post type default. self::write_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', $value === 'noindex' ? '1' : ( $value === 'index' ? '2' : '' ) ); return; case 'rank_math': // One array of directives: only index/noindex are replaced, nofollow & co. are kept. $robots = get_post_meta( $post_id, 'rank_math_robots', true ); $robots = array_values( array_diff( is_array( $robots ) ? $robots : [], [ 'index', 'noindex' ] ) ); if ( $value !== 'default' ) { $robots[] = $value; } if ( empty( $robots ) ) { delete_post_meta( $post_id, 'rank_math_robots' ); } else { update_post_meta( $post_id, 'rank_math_robots', $robots ); } return; case 'seopress': // A single "noindex" box: no per-post forced index. self::write_meta( $post_id, '_seopress_robots_index', $value === 'noindex' ? 'yes' : '' ); return; case 'the_seo_framework': // Qubit: 1 = noindex, -1 = index, absent = default. self::write_meta( $post_id, '_genesis_noindex', $value === 'noindex' ? '1' : ( $value === 'index' ? '-1' : '' ) ); return; case 'agentsia_theme': // A single box: '1' = noindex, otherwise indexed. self::write_meta( $post_id, (string) self::fields_of( $provider )['noindex'], $value === 'noindex' ? '1' : '' ); return; default: self::write_meta( $post_id, self::META_NOINDEX, $value === 'noindex' ? '1' : ( $value === 'index' ? '0' : '' ) ); } } private function read_noindex_value( string $provider, int $post_id ): ?bool { switch ( $provider ) { case 'yoast': $v = (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true ); return $v === '1' ? true : ( $v === '2' ? false : null ); case 'rank_math': $robots = get_post_meta( $post_id, 'rank_math_robots', true ); $robots = is_array( $robots ) ? $robots : []; return in_array( 'noindex', $robots, true ) ? true : ( in_array( 'index', $robots, true ) ? false : null ); case 'seopress': return get_post_meta( $post_id, '_seopress_robots_index', true ) === 'yes' ? true : null; case 'the_seo_framework': $v = (string) get_post_meta( $post_id, '_genesis_noindex', true ); return $v === '1' ? true : ( $v === '-1' ? false : null ); case 'agentsia_theme': $key = self::fields_of( $provider )['noindex']; return $key !== null && (string) get_post_meta( $post_id, $key, true ) === '1' ? true : null; default: $v = (string) get_post_meta( $post_id, self::META_NOINDEX, true ); return $v === '1' ? true : ( $v === '0' ? false : null ); } } /** * AIOSEO 4 keeps these fields in its own `aioseo_posts` table, behind its model. * * @return true|\WP_Error */ private function write_aioseo( int $post_id, ?string $title, ?string $description, ?string $canonical, ?string $noindex ) { $model = $this->aioseo_model( $post_id ); if ( $model === null ) { return new \WP_Error( 'agentsia_seo_meta_provider_unsupported', __( 'Version d\'All in One SEO non prise en charge.', 'agentsia-agents' ), [ 'status' => 501 ] ); } if ( $title !== null ) { $model->title = $title === '' ? null : $title; } if ( $description !== null ) { $model->description = $description === '' ? null : $description; } if ( $canonical !== null ) { $model->canonical_url = $canonical === '' ? null : $canonical; } if ( $noindex !== null ) { // `robots_default` = follow the global settings; turning it off makes the per-post fields apply. $model->robots_default = $noindex === 'default'; $model->robots_noindex = $noindex === 'noindex'; } $model->post_id = $post_id; $model->save(); return true; } /** @return object|null */ private function aioseo_model( int $post_id ) { $class = '\\AIOSEO\\Plugin\\Common\\Models\\Post'; if ( ! class_exists( $class ) || ! method_exists( $class, 'getPost' ) ) { return null; } return $class::getPost( $post_id ); } /** * Stored values (not the rendered tags): `null` means "not set", the SEO plugin then uses its settings. */ private function snapshot( int $post_id ): array { $provider = self::detect_provider(); $title = null; $desc = null; $canonical = null; $noindex = null; if ( $provider === 'aioseo' ) { $model = $this->aioseo_model( $post_id ); if ( $model !== null ) { $title = self::nullable_string( $model->title ?? null ); $desc = self::nullable_string( $model->description ?? null ); $canonical = self::nullable_string( $model->canonical_url ?? null ); $noindex = ! empty( $model->robots_default ) ? null : ! empty( $model->robots_noindex ); } } else { $fields = self::fields_of( $provider ); $title = self::nullable_string( get_post_meta( $post_id, $fields['title'], true ) ); $desc = self::nullable_string( get_post_meta( $post_id, $fields['description'], true ) ); $canonical = $fields['canonical'] !== null ? self::nullable_string( get_post_meta( $post_id, $fields['canonical'], true ) ) : null; $noindex = $this->read_noindex_value( $provider, $post_id ); } return [ 'id' => $post_id, 'type' => get_post_type( $post_id ), 'status' => get_post_status( $post_id ), 'link' => get_permalink( $post_id ), 'post_title' => get_the_title( $post_id ), 'is_front_page' => self::is_front_page_id( $post_id ), 'provider' => $provider, 'provider_label' => self::provider_label( $provider ), 'title' => $title, 'description' => $desc, 'noindex' => $noindex, 'canonical' => $canonical, // Always present (null when none): its key tells the backend this version stores the JSON-LD. 'schema_jsonld' => self::nullable_string( get_post_meta( $post_id, self::META_SCHEMA_JSONLD, true ) ), 'plugin_version' => AGENTSIA_AGENTS_VERSION, ]; } public static function is_front_page_id( int $post_id ): bool { return get_option( 'show_on_front' ) === 'page' && (int) get_option( 'page_on_front' ) === $post_id; } /** @param mixed $value */ public static function nullable_string( $value ): ?string { return is_string( $value ) && trim( $value ) !== '' ? $value : null; } /** * Post from `id`, or from `url` (static front page included). Shared with the image route. * * @return int|\WP_Error */ public static function resolve_post_id( \WP_REST_Request $request ) { $id = (int) $request->get_param( 'id' ); if ( $id <= 0 ) { $url = trim( (string) $request->get_param( 'url' ) ); if ( $url === '' ) { return new \WP_Error( 'agentsia_seo_meta_missing_target', __( 'Paramètre url ou id requis.', 'agentsia-agents' ), [ 'status' => 400 ] ); } $id = url_to_postid( $url ); if ( $id <= 0 ) { // A member's page created as a draft from Agentsia, by its final address. $id = Site_Members::unpublished_post_at( $url ); } if ( $id <= 0 && self::is_home_url( $url ) ) { if ( get_option( 'show_on_front' ) !== 'page' ) { return new \WP_Error( 'agentsia_seo_meta_home_archive', __( 'La page d\'accueil affiche les derniers articles : son titre se règle dans l\'extension SEO, pas sur une page.', 'agentsia-agents' ), [ 'status' => 422 ] ); } $id = (int) get_option( 'page_on_front' ); } } $post = $id > 0 ? get_post( $id ) : null; if ( ! $post || ! is_post_type_viewable( $post->post_type ) || $post->post_type === 'attachment' ) { return new \WP_Error( 'agentsia_seo_meta_not_found', __( 'Aucune page ou article WordPress à cette adresse (archive, catégorie ou page inconnue).', 'agentsia-agents' ), [ 'status' => 404 ] ); } return (int) $post->ID; } private static function is_home_url( string $url ): bool { $home = wp_parse_url( home_url( '/' ) ); $asked = wp_parse_url( $url ); if ( ! is_array( $home ) || ! is_array( $asked ) ) { return false; } $strip_www = static function ( $host ) { return preg_replace( '/^www\./i', '', strtolower( (string) $host ) ); }; $home_path = rtrim( (string) ( $home['path'] ?? '' ), '/' ); $asked_path = rtrim( (string) ( $asked['path'] ?? '' ), '/' ); return $strip_www( $home['host'] ?? '' ) === $strip_www( $asked['host'] ?? '' ) && $home_path === $asked_path; } // ── Front-end output without SEO plugin ───────────────────────────────────── /** * @param string $title Title computed so far ('' = let WordPress build it). */ public function filter_document_title( $title ) { $custom = $this->own_value( self::META_TITLE ); // `pre_get_document_title` is printed without escaping by WordPress: escape it here. return $custom !== null ? esc_html( $custom ) : $title; } public function print_meta_description(): void { $description = $this->own_value( self::META_DESCRIPTION ); if ( $description === null ) { return; } echo '<meta name="description" content="' . esc_attr( $description ) . '" />' . "\n"; } /** * @param array $robots Directives built by WordPress (`wp_robots`, WordPress 5.7+). */ public function filter_robots( $robots ) { $value = $this->own_value( self::META_NOINDEX ); if ( $value === '1' && is_array( $robots ) ) { unset( $robots['index'] ); $robots['noindex'] = true; $robots['follow'] = true; } return $robots; } /** * @param string $url Canonical URL computed by WordPress. * @param \WP_Post $post Post it belongs to. */ public function filter_canonical( $url, $post ) { if ( self::detect_provider() !== 'agentsia' || ! $post instanceof \WP_Post ) { return $url; } $custom = self::nullable_string( get_post_meta( $post->ID, self::META_CANONICAL, true ) ); return $custom !== null ? $custom : $url; } /** Value stored by this plugin for the current singular page, only when no SEO plugin is active. */ private function own_value( string $key ): ?string { if ( ! is_singular() || self::detect_provider() !== 'agentsia' ) { return null; } $post_id = get_queried_object_id(); return $post_id ? self::nullable_string( get_post_meta( $post_id, $key, true ) ) : null; } }