(or ?id=) * POST /wp-json/agentsia/v1/images//alt * { "images": [ { "attachment_id": 12, "alt": "...", "library_alt": "..." }, { "src": "https://...", "alt": "..." } ] } * * Why the page content is edited too: the block editor and the classic editor copy the alt text INTO the * page HTML when the image is inserted. Changing the media library alone leaves every existing page * unchanged. So each image is matched in `post_content` (by its `wp-image-` class, or by its exact * address when it has no media id) and its `alt` attribute is rewritten; the library alt is updated too * (used by featured images and page builders such as Elementor, whose content is not in `post_content`). * * `library_alt` defaults to `alt`; it only differs when undoing a change (each one is restored). * The response is a fresh read of the page: the backend compares it with what it asked for. */ final class Images { const REST_ROUTE = '/images'; private const MAX_ALT = 300; private const MAX_ITEMS = 50; 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' ] ); } public function register_routes(): void { register_rest_route( Seo_Meta::REST_NAMESPACE, self::REST_ROUTE, [ 'methods' => 'GET', 'callback' => [ $this, 'handle_read' ], 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, 'args' => [ 'url' => [ 'type' => 'string' ], 'id' => [ 'type' => 'integer' ], ], ] ); register_rest_route( Seo_Meta::REST_NAMESPACE, self::REST_ROUTE . '/(?P\d+)/alt', [ '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 = Seo_Meta::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 ] ); } if ( ! class_exists( '\\WP_HTML_Tag_Processor' ) ) { return $this->too_old(); } 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 ] ); } if ( ! class_exists( '\\WP_HTML_Tag_Processor' ) ) { return $this->too_old(); } $params = $request->get_json_params(); $items = is_array( $params ) && isset( $params['images'] ) && is_array( $params['images'] ) ? $params['images'] : []; if ( empty( $items ) || count( $items ) > self::MAX_ITEMS ) { return new \WP_Error( 'agentsia_images_invalid', __( 'Liste d\'images vide ou trop longue (50 maximum).', 'agentsia-agents' ), [ 'status' => 400 ] ); } $changes = []; foreach ( $items as $item ) { if ( ! is_array( $item ) ) { return new \WP_Error( 'agentsia_images_invalid', __( 'Image invalide.', 'agentsia-agents' ), [ 'status' => 400 ] ); } $alt = Seo_Meta::read_text_field( $item, 'alt', self::MAX_ALT ); if ( is_wp_error( $alt ) ) { return $alt; } if ( $alt === null ) { return new \WP_Error( 'agentsia_images_invalid', __( 'Texte alternatif manquant.', 'agentsia-agents' ), [ 'status' => 400 ] ); } $library_alt = array_key_exists( 'library_alt', $item ) ? Seo_Meta::read_text_field( $item, 'library_alt', self::MAX_ALT ) : $alt; if ( is_wp_error( $library_alt ) ) { return $library_alt; } $attachment_id = isset( $item['attachment_id'] ) ? (int) $item['attachment_id'] : 0; $src = isset( $item['src'] ) && is_string( $item['src'] ) ? trim( $item['src'] ) : ''; if ( $attachment_id <= 0 && $src === '' ) { return new \WP_Error( 'agentsia_images_invalid', __( 'Chaque image doit porter attachment_id ou src.', 'agentsia-agents' ), [ 'status' => 400 ] ); } if ( $attachment_id > 0 && get_post_type( $attachment_id ) !== 'attachment' ) { return new \WP_Error( 'agentsia_images_invalid', __( 'Média introuvable.', 'agentsia-agents' ), [ 'status' => 400, 'attachment_id' => $attachment_id ] ); } $changes[] = [ 'attachment_id' => $attachment_id, 'src' => $src, 'alt' => $alt, 'library_alt' => $library_alt ]; } // 1. Media library (shared by every page using the image; also what Elementor & co. display). foreach ( $changes as $change ) { if ( $change['attachment_id'] > 0 && $change['library_alt'] !== null && current_user_can( 'edit_post', $change['attachment_id'] ) ) { if ( $change['library_alt'] === '' ) { delete_post_meta( $change['attachment_id'], '_wp_attachment_image_alt' ); } else { update_post_meta( $change['attachment_id'], '_wp_attachment_image_alt', wp_slash( $change['library_alt'] ) ); } } } // 2. The alt attribute copied into the page HTML. $content = (string) $post->post_content; $updated = $this->rewrite_alts( $content, $changes ); if ( $updated !== $content ) { // Only `alt` attribute values change, escaped by the tag processor. Without this, an editor // lacking `unfiltered_html` would see kses strip every iframe, form or script the page had. $had_kses = has_filter( 'content_save_pre', 'wp_filter_post_kses' ); kses_remove_filters(); $saved = wp_update_post( [ 'ID' => $post_id, 'post_content' => wp_slash( $updated ) ], true ); if ( $had_kses ) { kses_init_filters(); } if ( is_wp_error( $saved ) ) { return new \WP_Error( 'agentsia_images_save_failed', $saved->get_error_message(), [ 'status' => 500 ] ); } } clean_post_cache( $post_id ); return new \WP_REST_Response( $this->snapshot( $post_id ), 200 ); } /** Rewrites the alt of every matching a change (media id class first, exact address otherwise). */ private function rewrite_alts( string $content, array $changes ): string { $tags = new \WP_HTML_Tag_Processor( $content ); while ( $tags->next_tag( 'img' ) ) { $id = $this->attachment_id_of( $tags ); $src = (string) $tags->get_attribute( 'src' ); foreach ( $changes as $change ) { $matches = $change['attachment_id'] > 0 ? $id === $change['attachment_id'] : ( $id === 0 && $src === $change['src'] ); if ( $matches ) { $tags->set_attribute( 'alt', $change['alt'] ); break; } } } return $tags->get_updated_html(); } private function attachment_id_of( \WP_HTML_Tag_Processor $tags ): int { $class = (string) $tags->get_attribute( 'class' ); return preg_match( '/\bwp-image-(\d+)\b/', $class, $m ) ? (int) $m[1] : 0; } private function snapshot( int $post_id ): array { $images = []; $tags = new \WP_HTML_Tag_Processor( (string) get_post_field( 'post_content', $post_id ) ); while ( $tags->next_tag( 'img' ) ) { $id = $this->attachment_id_of( $tags ); $alt = $tags->get_attribute( 'alt' ); $images[] = [ 'attachment_id' => $id > 0 ? $id : null, 'src' => (string) $tags->get_attribute( 'src' ), 'alt' => is_string( $alt ) ? $alt : null, 'library_alt' => $id > 0 ? Seo_Meta::nullable_string( get_post_meta( $id, '_wp_attachment_image_alt', true ) ) : null, 'in_content' => true, 'featured' => false, ]; } $thumbnail = (int) get_post_thumbnail_id( $post_id ); if ( $thumbnail > 0 ) { $library = Seo_Meta::nullable_string( get_post_meta( $thumbnail, '_wp_attachment_image_alt', true ) ); $images[] = [ 'attachment_id' => $thumbnail, 'src' => (string) wp_get_attachment_url( $thumbnail ), 'alt' => $library, 'library_alt' => $library, 'in_content' => false, 'featured' => true, ]; } return [ 'id' => $post_id, 'type' => get_post_type( $post_id ), 'link' => get_permalink( $post_id ), 'post_title' => get_the_title( $post_id ), // Page builders keep their layout outside `post_content`: only the library alt reaches them. 'builder' => get_post_meta( $post_id, '_elementor_edit_mode', true ) === 'builder' ? 'elementor' : null, 'images' => $images, 'plugin_version' => AGENTSIA_AGENTS_VERSION, ]; } private function too_old(): \WP_Error { return new \WP_Error( 'agentsia_seo_meta_provider_unsupported', __( 'WordPress 6.2 ou plus est requis pour modifier les images.', 'agentsia-agents' ), [ 'status' => 501 ] ); } }