Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <page>" and shows the old value, an arrow and the new one, with the image name.
Expand Down
26 changes: 26 additions & 0 deletions src/Content.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions src/ImageAlt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php
namespace MonoRanks;

defined( 'ABSPATH' ) || exit;

/**
* Image alt text where it renders. A picture in a post body carries its own alt attribute, which wins over the alt stored
* on the attachment, so MonoRanks reads and writes both. Plain string work on the <img> 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-<id> class WordPress adds.
*/
class ImageAlt {
/** The <img> 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( '/<img\b[^>]*>/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(
'/<img\b[^>]*>/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
);
}
}
55 changes: 45 additions & 10 deletions src/Writer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
Expand Down Expand Up @@ -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 ) {
Expand All @@ -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 );
}
}
64 changes: 64 additions & 0 deletions tests/unit/ImageAltTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
namespace MonoRanks\Tests;

use Brain\Monkey;
use Brain\Monkey\Functions;
use MonoRanks\ImageAlt;
use MonoRanks\Writer;
use Yoast\PHPUnitPolyfills\TestCases\TestCase;

/** Alt text is read and checked where it renders: the alt in the post body, else the attachment's stored alt. */
class ImageAltTest extends TestCase {
const BODY = '<!-- wp:image {"id":7} --><figure class="wp-block-image"><img src="https://e.test/a.jpg" alt="" class="wp-image-7"/></figure><!-- /wp:image --><p>x</p><img class="wp-image-9 size-full" src="https://e.test/b.jpg">';

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 &quot;red&quot; 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' ) ) );
}
}
Loading