From 63fe73b01bee46e2da587317a1684287bc055ed7 Mon Sep 17 00:00:00 2001 From: mostafa Date: Wed, 7 Oct 2026 19:06:22 +0400 Subject: [PATCH] Send stored image alt with content sync; apply where it renders --- CHANGELOG.md | 5 +++ src/Content.php | 26 +++++++++++++++ src/ImageAlt.php | 55 +++++++++++++++++++++++++++++++ src/Writer.php | 55 +++++++++++++++++++++++++------ tests/unit/ImageAltTest.php | 64 +++++++++++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 src/ImageAlt.php create mode 100644 tests/unit/ImageAltTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9e8f6..66e83b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to the MonoRanks WordPress plugin. The format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [Semantic Versioning](https://semver.org/). +## Unreleased + +- **Enhancement:** The content sync also sends each image in a post with the alt written in the post body, next to the alt stored on the image. +- **Fix:** Image alt text is checked and written where it shows: the alt in the post body when the image sits there, and the stored alt. Fixes are no longer refused as changed when the page and the stored alt differ. + ## v0.1.20 - 2026-10-07 - **Enhancement:** Each fix on the Overview reads "Image alt on " and shows the old value, an arrow and the new one, with the image name. diff --git a/src/Content.php b/src/Content.php index bef5828..4b9bff9 100644 --- a/src/Content.php +++ b/src/Content.php @@ -40,6 +40,32 @@ public static function item( \WP_Post $post ) { if ( $thumb && ! wp_list_filter( $images, array( 'id' => $thumb ) ) ) { $images[] = array( 'id' => (int) $thumb, 'src' => wp_get_attachment_url( $thumb ), 'alt' => (string) get_post_meta( $thumb, '_wp_attachment_image_alt', true ) ); } + // Images in the body: WordPress renders the alt attribute written there, not the attachment's stored alt, so both go to MonoRanks. + foreach ( ImageAlt::images( $post->post_content ) as $found ) { + if ( ! $found['id'] ) { + continue; + } + $content_alt = null === $found['alt'] ? '' : $found['alt']; + $at = null; + foreach ( $images as $i => $known ) { + if ( (int) $known['id'] === $found['id'] ) { + $at = $i; + break; + } + } + if ( null === $at ) { + $src = wp_get_attachment_url( $found['id'] ); + if ( ! $src ) { + continue; + } + $images[] = array( 'id' => $found['id'], 'src' => $src, 'alt' => (string) get_post_meta( $found['id'], '_wp_attachment_image_alt', true ) ); + $at = count( $images ) - 1; + } + if ( empty( $images[ $at ]['in_content'] ) ) { + $images[ $at ]['in_content'] = true; + $images[ $at ]['content_alt'] = $content_alt; + } + } $type_object = get_post_type_object( $post->post_type ); return array( 'id' => $post->ID, diff --git a/src/ImageAlt.php b/src/ImageAlt.php new file mode 100644 index 0000000..8248438 --- /dev/null +++ b/src/ImageAlt.php @@ -0,0 +1,55 @@ + tags (no DOM round trip), so the + * rest of the post markup stays byte for byte as it was. An image is matched by the wp-image- class WordPress adds. + */ +class ImageAlt { + /** The tags of a body: attachment id (0 when unknown), src, and the alt attribute (null when there is none). */ + public static function images( $html ) { + $out = array(); + if ( ! preg_match_all( '/]*>/i', (string) $html, $tags ) ) { + return $out; + } + foreach ( $tags[0] as $tag ) { + $id = preg_match( '/\bclass\s*=\s*(["\'])[^"\']*\bwp-image-(\d+)\b/i', $tag, $m ) ? (int) $m[2] : 0; + $src = preg_match( '/\ssrc\s*=\s*(["\'])(.*?)\1/is', $tag, $m ) ? html_entity_decode( $m[2] ) : ''; + $alt = preg_match( '/\salt\s*=\s*(["\'])(.*?)\1/is', $tag, $m ) ? html_entity_decode( $m[2], ENT_QUOTES ) : null; + $out[] = array( 'id' => $id, 'src' => $src, 'alt' => $alt ); + } + return $out; + } + + /** The alt in the body for one attachment: null when the body has no such image, '' when it has no alt attribute. */ + public static function body_alt( $html, $attachment_id ) { + foreach ( self::images( $html ) as $img ) { + if ( (int) $attachment_id === $img['id'] ) { + return null === $img['alt'] ? '' : $img['alt']; + } + } + return null; + } + + /** The body with the alt of every image of that attachment set to $alt. */ + public static function set_body_alt( $html, $attachment_id, $alt ) { + $attr = ' alt="' . esc_attr( $alt ) . '"'; + return preg_replace_callback( + '/]*>/i', + static function ( $m ) use ( $attachment_id, $attr ) { + $tag = $m[0]; + if ( ! preg_match( '/\bclass\s*=\s*(["\'])[^"\']*\bwp-image-' . (int) $attachment_id . '\b/i', $tag ) ) { + return $tag; + } + if ( preg_match( '/\salt\s*=\s*(["\'])(.*?)\1/is', $tag ) ) { + return preg_replace_callback( '/\salt\s*=\s*(["\'])(.*?)\1/is', static function () use ( $attr ) { return $attr; }, $tag, 1 ); + } + return preg_replace( '/\s*\/?>$/', $attr . ' />', $tag, 1 ); + }, + (string) $html + ); + } +} diff --git a/src/Writer.php b/src/Writer.php index cf78dc3..35120ba 100644 --- a/src/Writer.php +++ b/src/Writer.php @@ -77,15 +77,22 @@ private static function one( array $c, $actor ) { if ( ! $attachment || 'attachment' !== get_post_type( $attachment ) ) { return array( 'id' => $id, 'ok' => false, 'error' => 'attachment_not_found' ); } - $previous = (string) get_post_meta( $attachment, '_wp_attachment_image_alt', true ); - if ( isset( $c['expected'] ) && (string) $c['expected'] !== $previous ) { + // Where the alt renders: the alt attribute in the post body when the image sits there, else the attachment's stored alt. + $page = isset( $c['post_id'] ) ? (int) $c['post_id'] : 0; + $state = self::alt_state( $attachment, $page ); + $previous = $state['current']; + if ( isset( $c['expected'] ) && (string) $c['expected'] !== $state['current'] && (string) $c['expected'] !== $state['stored'] ) { return array( 'id' => $id, 'ok' => false, 'error' => 'changed_since_preview', 'current' => $previous ); } - update_post_meta( $attachment, '_wp_attachment_image_alt', wp_slash( sanitize_text_field( $value ) ) ); - self::log( $actor, $field, 'attachment:' . $attachment, $previous, $value ); + $clean = sanitize_text_field( $value ); + update_post_meta( $attachment, '_wp_attachment_image_alt', wp_slash( $clean ) ); + if ( null !== $state['body'] && $state['body'] !== $clean ) { + self::restore( $page, ImageAlt::set_body_alt( $state['content'], $attachment, $clean ) ); + } + self::log( $actor, $field, 'attachment:' . $attachment, $previous, $value, null !== $state['body'] ? $page : 0 ); $result = array( 'id' => $id, 'ok' => true, 'previous' => $previous ); // The page that shows the image: the post the change names, else the post the image was uploaded to (#11). - $page = isset( $c['post_id'] ) ? (int) $c['post_id'] : (int) wp_get_post_parent_id( $attachment ); + $page = $page ? $page : (int) wp_get_post_parent_id( $attachment ); if ( $page && 'publish' === get_post_status( $page ) ) { $result['cache'] = CachePurger::post( $page ); } @@ -327,6 +334,9 @@ public static function reverse( array $row ) { $c['from'] = $target; } elseif ( 'alt' === $field && 0 === strpos( $target, 'attachment:' ) ) { $c['attachment_id'] = (int) substr( $target, 11 ); + if ( ! empty( $row['post'] ) ) { + $c['post_id'] = (int) $row['post']; + } } elseif ( 0 === strpos( $target, 'post:' ) ) { $c['post_id'] = (int) substr( $target, 5 ); } elseif ( 'site' !== $target ) { @@ -344,21 +354,46 @@ public static function reverse( array $row ) { */ public static function current_value( array $fix ) { if ( isset( $fix['field'] ) && 'alt' === $fix['field'] && ! empty( $fix['attachment_id'] ) && 'attachment' === get_post_type( (int) $fix['attachment_id'] ) ) { - return (string) get_post_meta( (int) $fix['attachment_id'], '_wp_attachment_image_alt', true ); + $state = self::alt_state( (int) $fix['attachment_id'], empty( $fix['post_id'] ) ? 0 : (int) $fix['post_id'] ); + // The reviewed value may be the rendered alt or the stored one; only a value matching neither is out of date. + if ( isset( $fix['before'] ) && ( (string) $fix['before'] === $state['stored'] || (string) $fix['before'] === $state['current'] ) ) { + return (string) $fix['before']; + } + return $state['current']; } return null; } + /** + * What WordPress holds for one image: 'stored' (attachment alt), 'body' (the alt attribute in that post's body, null when + * the image is not in it, '' when it has none), 'content' (the body) and 'current' (what renders: body, else stored). + */ + public static function alt_state( $attachment, $post_id ) { + $stored = (string) get_post_meta( $attachment, '_wp_attachment_image_alt', true ); + $body = null; + $content = ''; + $post = $post_id ? get_post( $post_id ) : null; + if ( $post ) { + $content = (string) $post->post_content; + $body = ImageAlt::body_alt( $content, $attachment ); + } + return array( 'stored' => $stored, 'body' => $body, 'content' => $content, 'current' => null === $body ? $stored : $body ); + } + private static function same_site( $url ) { $host = wp_parse_url( home_url(), PHP_URL_HOST ); $to = wp_parse_url( $url, PHP_URL_HOST ); return ! $to || preg_replace( '/^www\./', '', (string) $to ) === preg_replace( '/^www\./', '', (string) $host ); } - private static function log( $actor, $field, $target, $previous, $value ) { - $log = get_option( 'monoranks_change_log', array() ); - $log = is_array( $log ) ? $log : array(); - $log[] = array( 'at' => gmdate( 'c' ), 'actor' => sanitize_text_field( (string) $actor ), 'field' => $field, 'target' => $target, 'previous' => $previous, 'value' => $value ); + private static function log( $actor, $field, $target, $previous, $value, $post = 0 ) { + $log = get_option( 'monoranks_change_log', array() ); + $log = is_array( $log ) ? $log : array(); + $row = array( 'at' => gmdate( 'c' ), 'actor' => sanitize_text_field( (string) $actor ), 'field' => $field, 'target' => $target, 'previous' => $previous, 'value' => $value ); + if ( $post ) { + $row['post'] = (int) $post; + } + $log[] = $row; update_option( 'monoranks_change_log', array_slice( $log, -200 ), false ); } } diff --git a/tests/unit/ImageAltTest.php b/tests/unit/ImageAltTest.php new file mode 100644 index 0000000..b0a9fe3 --- /dev/null +++ b/tests/unit/ImageAltTest.php @@ -0,0 +1,64 @@ +

x

'; + + protected function set_up() { + parent::set_up(); + Monkey\setUp(); + Functions\when( 'esc_attr' )->alias( 'htmlspecialchars' ); + } + protected function tear_down() { + Monkey\tearDown(); + parent::tear_down(); + } + + public function test_reads_the_alt_attribute_of_each_image_in_the_body() { + $images = ImageAlt::images( self::BODY ); + $this->assertSame( array( 7, 9 ), array( $images[0]['id'], $images[1]['id'] ) ); + $this->assertSame( '', $images[0]['alt'] ); + $this->assertNull( $images[1]['alt'] ); + $this->assertSame( '', ImageAlt::body_alt( self::BODY, 9 ) ); + $this->assertNull( ImageAlt::body_alt( self::BODY, 11 ) ); + } + + public function test_sets_the_alt_on_that_image_only_and_leaves_the_rest_alone() { + $out = ImageAlt::set_body_alt( self::BODY, 9, 'A "red" door' ); + $this->assertStringContainsString( 'src="https://e.test/b.jpg" alt="A "red" door" />', $out ); + $this->assertStringContainsString( 'alt="" class="wp-image-7"/>', $out ); + $out = ImageAlt::set_body_alt( self::BODY, 7, 'Door' ); + $this->assertStringContainsString( 'alt="Door" class="wp-image-7"/>', $out ); + $this->assertSame( 'Door', ImageAlt::body_alt( $out, 7 ) ); + } + + private function wp( $stored, $content ) { + Functions\when( 'get_post_meta' )->justReturn( $stored ); + Functions\when( 'get_post' )->justReturn( (object) array( 'post_content' => $content ) ); + Functions\when( 'get_post_type' )->justReturn( 'attachment' ); + } + + public function test_the_alt_that_renders_is_the_body_alt_when_the_image_is_in_the_post() { + $this->wp( 'Stored', self::BODY ); + $state = Writer::alt_state( 7, 5 ); + $this->assertSame( 'Stored', $state['stored'] ); + $this->assertSame( '', $state['current'] ); + $this->assertSame( 'Stored', Writer::alt_state( 7, 0 )['current'] ); + $this->assertSame( 'Stored', Writer::alt_state( 11, 5 )['current'] ); + } + + public function test_a_fix_is_out_of_date_only_when_its_value_matches_neither_place() { + $this->wp( 'Stored', self::BODY ); + $fix = array( 'field' => 'alt', 'attachment_id' => 7, 'post_id' => 5 ); + $this->assertSame( '', Writer::current_value( $fix + array( 'before' => '' ) ) ); + $this->assertSame( 'Stored', Writer::current_value( $fix + array( 'before' => 'Stored' ) ) ); + $this->assertSame( '', Writer::current_value( $fix + array( 'before' => 'Someone else' ) ) ); + } +}