` on the front page only, whatever the * SEO plugin. It is a setting of the site, not a post meta: it never replaces the structured data a page holds. * Its `@id` follows the SEO plugins (`/#organization`), so readers merge both descriptions. * * GET /wp-json/agentsia/v1/llms-txt → { content, url, atRoot, physicalFile } * POST /wp-json/agentsia/v1/llms-txt { "content": "# …" } ('' removes it) * GET /wp-json/agentsia/v1/organization-schema → { schema_jsonld, frontPageUrl, logoCandidates } * POST /wp-json/agentsia/v1/organization-schema { "schema_jsonld": "{…}" } ('' removes it) * * Writing a site-level file is an administrator action: `manage_options`, or `agentsia_seo` for the dedicated * Agentsia user created by the connection button. The POST responses are a fresh read of what is stored. */ final class Site_Identity { const REST_NAMESPACE = 'agentsia/v1'; const OPTION_LLMS_TXT = 'agentsia_llms_txt'; const OPTION_ORGANIZATION = 'agentsia_organization_schema'; /** Far above a real llms.txt (a few KB): only rejects obvious mistakes. */ private const MAX_LLMS_TXT_LENGTH = 100000; private const MAX_SCHEMA_LENGTH = 50000; 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' ] ); // Priority 0: before WordPress resolves the (non-existent) /llms.txt to a 404 template. add_action( 'template_redirect', [ $this, 'maybe_serve_llms_txt' ], 0 ); add_action( 'wp_head', [ $this, 'print_organization_schema' ], 30 ); } public static function can_manage(): bool { return current_user_can( 'manage_options' ) || current_user_can( 'agentsia_seo' ); } public function register_routes(): void { register_rest_route( self::REST_NAMESPACE, '/llms-txt', [ [ 'methods' => 'GET', 'callback' => [ $this, 'handle_get_llms_txt' ], 'permission_callback' => [ self::class, 'can_manage' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'handle_set_llms_txt' ], 'permission_callback' => [ self::class, 'can_manage' ], ], ] ); register_rest_route( self::REST_NAMESPACE, '/organization-schema', [ [ 'methods' => 'GET', 'callback' => [ $this, 'handle_get_organization_schema' ], 'permission_callback' => [ self::class, 'can_manage' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'handle_set_organization_schema' ], 'permission_callback' => [ self::class, 'can_manage' ], ], ] ); } // ── llms.txt ───────────────────────────────────────────────────────────────── public function handle_get_llms_txt(): \WP_REST_Response { return new \WP_REST_Response( self::llms_txt_state(), 200 ); } /** * @return \WP_REST_Response|\WP_Error */ public function handle_set_llms_txt( \WP_REST_Request $request ) { $raw = $request->get_param( 'content' ); if ( ! is_string( $raw ) ) { return new \WP_Error( 'agentsia_llms_txt_invalid', __( 'content attend une chaîne de caractères.', 'agentsia-agents' ), [ 'status' => 400 ] ); } $content = str_replace( [ "\r\n", "\r", "\0" ], [ "\n", "\n", '' ], wp_check_invalid_utf8( $raw ) ); $content = trim( $content ); if ( strlen( $content ) > self::MAX_LLMS_TXT_LENGTH ) { return new \WP_Error( 'agentsia_llms_txt_too_long', /* translators: %d: maximum number of characters. */ sprintf( __( 'llms.txt trop long (%d caractères maximum).', 'agentsia-agents' ), self::MAX_LLMS_TXT_LENGTH ), [ 'status' => 400 ] ); } if ( $content === '' ) { delete_option( self::OPTION_LLMS_TXT ); } else { // autoload = false: read only when /llms.txt is requested, not on every page. update_option( self::OPTION_LLMS_TXT, $content . "\n", false ); } return new \WP_REST_Response( self::llms_txt_state(), 200 ); } /** What is stored and where it is served, read fresh. */ public static function llms_txt_state(): array { $stored = get_option( self::OPTION_LLMS_TXT, '' ); return [ 'content' => is_string( $stored ) && $stored !== '' ? $stored : null, 'url' => home_url( '/llms.txt' ), // llms.txt is read at the root of the domain: a site installed in a sub-directory serves it elsewhere. 'atRoot' => self::home_path() === '/', // A real file on the server is served by the web server before WordPress: ours would never be read. 'physicalFile' => self::physical_llms_txt_exists(), ]; } /** Path of the home URL, always with a leading and trailing slash ('/' at the root). */ private static function home_path(): string { $path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); return '/' . trim( $path, '/' ) . ( trim( $path, '/' ) === '' ? '' : '/' ); } private static function physical_llms_txt_exists(): bool { $candidates = [ trailingslashit( ABSPATH ) . 'llms.txt' ]; if ( ! function_exists( 'get_home_path' ) && file_exists( ABSPATH . 'wp-admin/includes/file.php' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( function_exists( 'get_home_path' ) ) { $candidates[] = trailingslashit( get_home_path() ) . 'llms.txt'; } foreach ( array_unique( $candidates ) as $candidate ) { if ( @file_exists( $candidate ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- open_basedir may forbid the check. return true; } } return false; } /** * Serve the stored llms.txt as text when the request path is exactly /llms.txt. * No-op otherwise (lets WordPress handle the request normally). */ public function maybe_serve_llms_txt(): void { $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never printed. $path = rawurldecode( (string) wp_parse_url( (string) $request_uri, PHP_URL_PATH ) ); if ( $path !== self::home_path() . 'llms.txt' ) { return; } $content = get_option( self::OPTION_LLMS_TXT, '' ); if ( ! is_string( $content ) || $content === '' ) { return; } status_header( 200 ); header( 'Content-Type: text/plain; charset=UTF-8' ); header( 'X-Content-Type-Options: nosniff' ); // Plain text served as text/plain with nosniff: no markup is interpreted by the browser. echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped exit; } // ── Organization JSON-LD ──────────────────────────────────────────────────── public function handle_get_organization_schema(): \WP_REST_Response { return new \WP_REST_Response( self::organization_state(), 200 ); } /** * @return \WP_REST_Response|\WP_Error */ public function handle_set_organization_schema( \WP_REST_Request $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) ) { $params = $request->get_params(); } $encoded = self::read_schema( $params ); if ( is_wp_error( $encoded ) ) { return $encoded; } if ( $encoded === '' ) { delete_option( self::OPTION_ORGANIZATION ); } else { update_option( self::OPTION_ORGANIZATION, $encoded, false ); } return new \WP_REST_Response( self::organization_state(), 200 ); } public static function organization_state(): array { $stored = get_option( self::OPTION_ORGANIZATION, '' ); return [ 'schema_jsonld' => is_string( $stored ) && $stored !== '' ? $stored : null, 'frontPageUrl' => home_url( '/' ), 'logoCandidates' => self::logo_candidates(), ]; } /** * The images WordPress already knows as the site's logo: the site icon (Settings › General, cropped square by * WordPress) and the logo of the theme (Customizer, or the Site Logo block of a block theme). The agent adds one * of them as the `logo` of the block when no SEO plugin declares a logo; the plugin only reports what is there. */ public static function logo_candidates(): array { $custom_logo_id = (int) get_theme_mod( 'custom_logo' ); if ( $custom_logo_id <= 0 ) { $custom_logo_id = (int) get_option( 'site_logo' ); } return [ 'siteIcon' => self::image_candidate( (int) get_option( 'site_icon' ) ), 'customLogo' => self::image_candidate( $custom_logo_id ), ]; } /** Address, size and type of an image of the media library; null when unset, deleted or not an image. */ private static function image_candidate( int $attachment_id ): ?array { if ( $attachment_id <= 0 ) { return null; } $mime = (string) get_post_mime_type( $attachment_id ); if ( strpos( $mime, 'image/' ) !== 0 ) { return null; } $src = wp_get_attachment_image_src( $attachment_id, 'full' ); if ( ! is_array( $src ) || empty( $src[0] ) ) { return null; } return [ 'url' => (string) $src[0], // 0 when WordPress does not know the size (an SVG without metadata): the agent then leaves it out. 'width' => (int) ( $src[1] ?? 0 ), 'height' => (int) ( $src[2] ?? 0 ), 'mimeType' => $mime, ]; } /** * A JSON object given as a string, decoded then RE-ENCODED with `<`, `>` and `&` escaped, so the stored value * can never close the `' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON re-encoded with JSON_HEX_TAG | JSON_HEX_AMP on write. } }