Plugin Directory

Changeset 3722856


Ignore:
Timestamp:
10/01/2026 10:02:46 AM (3 weeks ago)
Author:
eskapism
Message:

Update to version 1.5.0 from GitHub

Location:
simple-seo
Files:
30 edited
1 copied

Legend:

Unmodified
Added
Removed
  • simple-seo/tags/1.5.0/build/editor-panel.asset.php

    r3720725 r3722856  
    22        'dependencies' => array(
    33                'react-jsx-runtime',
     4                'wp-block-editor',
    45                'wp-components',
    56                'wp-core-data',
    … …  
    1011                'wp-plugins'
    1112        ),
    12         'version' => '15619d0c8ed75730152f'
     13        'version' => '34f3d3968f2319195d78'
    1314);
  • simple-seo/tags/1.5.0/build/editor-panel.js

    r3720725 r3722856  
    1 (()=>{"use strict";const e=window.wp.plugins,s=window.wp.editor,i=window.wp.coreData,t=window.wp.data,o=window.wp.element,n=window.wp.components,l=window.wp.i18n,r=window.ReactJSXRuntime,{otherPlugin:a,simpleHistoryUrl:p,frontPageId:_,faqUrl:m}=window.simpleSeoEditor||{};(0,e.registerPlugin)("simple-seo",{render:function(){const e=(0,t.useSelect)(e=>e(s.store).getCurrentPostType(),[]),d=(0,t.useSelect)(e=>e(s.store).getCurrentPostId(),[]),[c,h]=(0,i.useEntityProp)("postType",e,"meta");if(!c)return null;const w=""!==c._simple_seo_title.trim()||""!==c._simple_seo_description.trim()||!!c._simple_seo_noindex,u=e=>s=>h({[e]:s});return(0,r.jsx)(s.PluginDocumentSettingPanel,{name:"simple-seo",title:(0,l.__)("Simple SEO","simple-seo"),children:(0,r.jsxs)(n.Flex,{direction:"column",gap:4,children:[a&&(0,r.jsx)(n.Notice,{status:"warning",isDismissible:!1,children:(0,l.sprintf)(/* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */ /* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */
    2 (0,l.__)("%s plugin is active and handles SEO, so these fields aren’t used.","simple-seo"),a)}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow simple-seo-title",rows:1,label:(0,l.__)("SEO title","simple-seo"),help:d===_?(0,l.__)("The whole title of the front page.","simple-seo"):(0,l.__)("About 50 characters. The site name is added after it.","simple-seo"),value:c._simple_seo_title,onChange:e=>u("_simple_seo_title")(e.replace(/[\r\n]+/g," ")),onKeyDown:e=>{"Enter"===e.key&&e.preventDefault()}}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow",rows:2,label:(0,l.__)("Meta description","simple-seo"),help:(0,l.__)("Often shown in search results.","simple-seo"),value:c._simple_seo_description,onChange:u("_simple_seo_description")}),(0,r.jsx)(n.CheckboxControl,{__nextHasNoMarginBottom:!0,label:(0,l.__)("Discourage search engines from indexing this page","simple-seo"),help:(0,l.__)("It’s up to search engines to honor this request.","simple-seo"),checked:!!c._simple_seo_noindex,onChange:e=>h({_simple_seo_noindex:e})}),p&&w&&(0,r.jsx)("p",{className:"simple-seo-tip",children:(0,o.createInterpolateElement)((0,l.__)("Tip: <a>Simple History</a> logs every change to these fields.","simple-seo"),{a:(0,r.jsx)("a",{href:p})})}),(0,r.jsx)("div",{children:(0,r.jsx)(n.ExternalLink,{href:m,children:(0,l.__)("Learn more about these fields","simple-seo")})})]})})}})})();
     1(()=>{"use strict";const e=window.wp.plugins,s=window.wp.editor,i=window.wp.blockEditor,t=window.wp.coreData,o=window.wp.data,l=window.wp.element,n=window.wp.components,a=window.wp.i18n,r=window.ReactJSXRuntime,{otherPlugin:_,simpleHistoryUrl:p,frontPageId:m,faqUrl:d}=window.simpleSeoEditor||{};function c({imageId:e,onChange:s}){const l=(0,o.useSelect)(s=>e?s(t.store).getMedia(e):void 0,[e]),_=l?.media_details?.sizes?.medium?.source_url??l?.source_url;return(0,r.jsx)(i.MediaUploadCheck,{children:(0,r.jsxs)(n.BaseControl,{__nextHasNoMarginBottom:!0,id:"simple-seo-share-image",help:(0,a.__)("Used in link previews instead of the featured image.","simple-seo"),children:[(0,r.jsx)(n.BaseControl.VisualLabel,{children:(0,a.__)("Share image","simple-seo")}),(0,r.jsx)(i.MediaUpload,{title:(0,a.__)("Share image","simple-seo"),allowedTypes:["image"],value:e,onSelect:e=>s(e.id),render:({open:i})=>(0,r.jsxs)(n.Flex,{direction:"column",gap:2,align:"flex-start",children:[_&&(0,r.jsx)("img",{src:_,alt:l?.alt_text??"",style:{maxWidth:"100%",height:"auto"}}),(0,r.jsxs)(n.Flex,{justify:"flex-start",gap:2,children:[(0,r.jsx)(n.Button,{__next40pxDefaultSize:!0,id:"simple-seo-share-image","aria-describedby":"simple-seo-share-image__help",variant:"secondary",onClick:i,children:e?(0,a.__)("Replace","simple-seo"):(0,a.__)("Set share image","simple-seo")}),!!e&&(0,r.jsx)(n.Button,{__next40pxDefaultSize:!0,variant:"tertiary",isDestructive:!0,onClick:()=>s(0),children:(0,a.__)("Remove","simple-seo")})]})]})})]})})}(0,e.registerPlugin)("simple-seo",{render:function(){const e=(0,o.useSelect)(e=>e(s.store).getCurrentPostType(),[]),i=(0,o.useSelect)(e=>e(s.store).getCurrentPostId(),[]),[h,g]=(0,t.useEntityProp)("postType",e,"meta");if(!h)return null;const u=""!==h._simple_seo_title.trim()||""!==h._simple_seo_description.trim()||!!h._simple_seo_noindex||!!h._simple_seo_share_image,x=e=>s=>g({[e]:s});return(0,r.jsx)(s.PluginDocumentSettingPanel,{name:"simple-seo",title:(0,a.__)("Simple SEO","simple-seo"),children:(0,r.jsxs)(n.Flex,{direction:"column",gap:4,children:[_&&(0,r.jsx)(n.Notice,{status:"warning",isDismissible:!1,children:(0,a.sprintf)(/* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */ /* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */
     2(0,a.__)("%s plugin is active and handles SEO, so these fields aren’t used.","simple-seo"),_)}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow simple-seo-title",rows:1,label:(0,a.__)("SEO title","simple-seo"),help:i===m?(0,a.__)("The whole title of the front page.","simple-seo"):(0,a.__)("About 50 characters. The site name is added after it.","simple-seo"),value:h._simple_seo_title,onChange:e=>x("_simple_seo_title")(e.replace(/[\r\n]+/g," ")),onKeyDown:e=>{"Enter"===e.key&&e.preventDefault()}}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow",rows:2,label:(0,a.__)("Meta description","simple-seo"),help:(0,a.__)("Often shown in search results.","simple-seo"),value:h._simple_seo_description,onChange:x("_simple_seo_description")}),(0,r.jsx)(c,{imageId:h._simple_seo_share_image??0,onChange:x("_simple_seo_share_image")}),(0,r.jsx)(n.CheckboxControl,{__nextHasNoMarginBottom:!0,label:(0,a.__)("Discourage search engines from indexing this page","simple-seo"),help:(0,a.__)("It’s up to search engines to honor this request.","simple-seo"),checked:!!h._simple_seo_noindex,onChange:e=>g({_simple_seo_noindex:e})}),p&&u&&(0,r.jsx)("p",{className:"simple-seo-tip",children:(0,l.createInterpolateElement)((0,a.__)("Tip: <a>Simple History</a> logs every change to these fields.","simple-seo"),{a:(0,r.jsx)("a",{href:p})})}),(0,r.jsx)("div",{children:(0,r.jsx)(n.ExternalLink,{href:d,children:(0,a.__)("Learn more about these fields","simple-seo")})})]})})}})})();
  • simple-seo/tags/1.5.0/js/editor-panel.js

    r3720725 r3722856  
    1111        store as editorStore,
    1212} from '@wordpress/editor';
    13 import { useEntityProp } from '@wordpress/core-data';
     13import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
     14import { store as coreStore, useEntityProp } from '@wordpress/core-data';
    1415import { useSelect } from '@wordpress/data';
    1516import { createInterpolateElement } from '@wordpress/element';
    1617import {
     18        BaseControl,
     19        Button,
    1720        CheckboxControl,
    1821        ExternalLink,
    … …  
    2629        window.simpleSeoEditor || {};
    2730
     31/**
     32 * The post's share image: a preview and the media library buttons, like core's
     33 * Featured image panel. Shows nothing for users who can't upload files.
     34 *
     35 * @param {Object}               props
     36 * @param {number}               props.imageId  Attachment ID, or 0.
     37 * @param {(id: number) => void} props.onChange Called with the new attachment ID, 0 to remove it.
     38 */
     39function ShareImageControl( { imageId, onChange } ) {
     40        const media = useSelect(
     41                ( select ) =>
     42                        imageId ? select( coreStore ).getMedia( imageId ) : undefined,
     43                [ imageId ]
     44        );
     45        const previewUrl =
     46                media?.media_details?.sizes?.medium?.source_url ?? media?.source_url;
     47
     48        return (
     49                <MediaUploadCheck>
     50                        { /* A visual label, not a <label>: that would replace the button's own name. */ }
     51                        <BaseControl
     52                                __nextHasNoMarginBottom
     53                                id="simple-seo-share-image"
     54                                help={ __(
     55                                        'Used in link previews instead of the featured image.',
     56                                        'simple-seo'
     57                                ) }
     58                        >
     59                                <BaseControl.VisualLabel>
     60                                        { __( 'Share image', 'simple-seo' ) }
     61                                </BaseControl.VisualLabel>
     62                                <MediaUpload
     63                                        title={ __( 'Share image', 'simple-seo' ) }
     64                                        allowedTypes={ [ 'image' ] }
     65                                        value={ imageId }
     66                                        onSelect={ ( image ) => onChange( image.id ) }
     67                                        render={ ( { open } ) => (
     68                                                <Flex direction="column" gap={ 2 } align="flex-start">
     69                                                        { previewUrl && (
     70                                                                <img
     71                                                                        src={ previewUrl }
     72                                                                        alt={ media?.alt_text ?? '' }
     73                                                                        style={ {
     74                                                                                maxWidth: '100%',
     75                                                                                height: 'auto',
     76                                                                        } }
     77                                                                />
     78                                                        ) }
     79                                                        <Flex justify="flex-start" gap={ 2 }>
     80                                                                <Button
     81                                                                        __next40pxDefaultSize
     82                                                                        id="simple-seo-share-image"
     83                                                                        aria-describedby="simple-seo-share-image__help"
     84                                                                        variant="secondary"
     85                                                                        onClick={ open }
     86                                                                >
     87                                                                        { imageId
     88                                                                                ? __( 'Replace', 'simple-seo' )
     89                                                                                : __(
     90                                                                                                'Set share image',
     91                                                                                                'simple-seo'
     92                                                                                        ) }
     93                                                                </Button>
     94                                                                { !! imageId && (
     95                                                                        <Button
     96                                                                                __next40pxDefaultSize
     97                                                                                variant="tertiary"
     98                                                                                isDestructive
     99                                                                                onClick={ () => onChange( 0 ) }
     100                                                                        >
     101                                                                                { __( 'Remove', 'simple-seo' ) }
     102                                                                        </Button>
     103                                                                ) }
     104                                                        </Flex>
     105                                                </Flex>
     106                                        ) }
     107                                />
     108                        </BaseControl>
     109                </MediaUploadCheck>
     110        );
     111}
     112
    28113function SimpleSeoPanel() {
    29114        const postType = useSelect(
    … …  
    46131                meta._simple_seo_title.trim() !== '' ||
    47132                meta._simple_seo_description.trim() !== '' ||
    48                 !! meta._simple_seo_noindex;
     133                !! meta._simple_seo_noindex ||
     134                !! meta._simple_seo_share_image;
    49135
    50136        // Meta edits are merged, so pass only the changed key.
    … …  
    118204                                />
    119205
     206                                <ShareImageControl
     207                                        imageId={ meta._simple_seo_share_image ?? 0 }
     208                                        onChange={ update( '_simple_seo_share_image' ) }
     209                                />
     210
    120211                                <CheckboxControl
    121212                                        __nextHasNoMarginBottom
  • simple-seo/tags/1.5.0/readme.txt

    r3720763 r3722856  
    66Tested up to: 7.1
    77Requires PHP: 7.4
    8 Stable tag: 1.4.0
     8Stable tag: 1.5.0
    99License: GPLv2
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1111
    12 The SEO basics and nothing more: a title, a description and a "don't index" checkbox for every page. No upsells, no settings maze.
     12The SEO basics: a title, a description and a "don't index" checkbox for every page. Small, fast, and no upsell banners all over your admin.
    1313
    1414== Description ==
    … …  
    2424A few things just happen: link previews with the right title, description and image in Slack, Mastodon, Bluesky and friends. Pages you keep out of search are left out of the sitemap too. There's a panel in the block editor, a box in the Classic Editor, and the fields in Quick Edit. Using Yoast SEO or another big SEO plugin? Simple SEO steps aside.
    2525
    26 No settings page, no scores, no "Go Pro" banners, no extra database queries. <del>No nags.</del> Okay, one small grey tip about [Simple History](https://wordpress.org/plugins/simple-history/), my activity log plugin.
     26No scores, no "Go Pro" banners, no extra database queries. <del>No nags.</del> Okay, one small grey tip about [Simple History](https://wordpress.org/plugins/simple-history/), my activity log plugin.
    2727
    2828No plugin makes a page rank. Good content does. Simple SEO hands search engines and shared links the title, description and image you chose, and asks search engines to skip the pages you don't want listed. That's also all AI search needs from a plugin: no llms.txt, no "AI optimization".
    … …  
    3232Simple SEO is old. Like really old. It arrived on WordPress.org in August 2010 (seven weeks before Yoast SEO!). Of the SEO plugins still maintained today, only All in One SEO is older. Then it slept from 2012 to 2026.
    3333
    34 I woke it up mostly for myself: I wanted the SEO basics on my own sites without a plugin that asks for attention every time I log in. It stays small on purpose, so feature requests often get "that's a bit much for a plugin called Simple".
     34I woke it up mostly for myself: I wanted the SEO basics on my own sites without a plugin that asks for attention every time I log in. It grows when I need something on my own sites, but it stays small and fast on purpose.
    3535
    3636No big company or investors, just one developer: me, the guy behind [Simple History](https://simple-history.com/). With both active, every SEO change is logged: who, when, and what it said before. I also make [CMS Tree Page View](https://wordpress.org/plugins/cms-tree-page-view/), and the two work together too. If Simple SEO saves you time, [a donation](https://eskapism.se/sida/donate/) keeps it going.
    … …  
    6060A wide picture, 1200 × 630 pixels, not your logo. It's used in link previews when a post has no featured image.
    6161
     62= Can a post have a different image in link previews? =
     63
     64Yes, pick a share image in the post's Simple SEO fields. Link previews use it instead of the featured image, and your theme keeps showing the featured image. Handy when the picture that looks good on your site isn't the one that works in a shared link, like a card with the headline on it.
     65
    6266= I use Yoast SEO (or Rank Math, All in One SEO, SEOPress, The SEO Framework). =
    6367
    … …  
    7074= Can I use WP-CLI, the REST API or filters? =
    7175
    72 Yup. The fields are post meta: `_simple_seo_title`, `_simple_seo_description` and `_simple_seo_noindex`, in the REST API with `?context=edit`. Everything Simple SEO outputs goes through a filter first. See [the hooks](https://github.com/bonny/WordPress-Simple-SEO/blob/main/docs/hooks.md).
     76Yup. The fields are post meta: `_simple_seo_title`, `_simple_seo_description`, `_simple_seo_noindex` and `_simple_seo_share_image` (an attachment ID), in the REST API with `?context=edit`. Everything Simple SEO outputs goes through a filter first. See [the hooks](https://github.com/bonny/WordPress-Simple-SEO/blob/main/docs/hooks.md).
    7377
    7478= What happened to the menu label? =
    … …  
    7882== Screenshots ==
    7983
    80 1. A few SEO fields in the page sidebar of the block editor. That's it.
     841. The SEO fields in the page sidebar of the block editor.
    81852. See and edit the SEO of every page right from the Pages list, with Quick Edit.
    82863. The same column for posts, so you can spot the ones missing a description.
    83874. Works in the Classic Editor too.
    84 5. One setting: a default share image for link previews, in Settings → General.
     885. A default share image for link previews, in Settings → General.
    85896. With Simple History, every SEO change is logged: who, when, and what it said before.
    8690
    8791== Changelog ==
     92
     93= 1.5.0 (October 2026) =
     94- New: a share image for each post, used in link previews instead of the featured image. Your theme keeps showing the featured image. In both editors, and logged in Simple History.
    8895
    8996= 1.4.0 (September 2026) =
  • simple-seo/tags/1.5.0/simple-seo.php

    r3720725 r3722856  
    44 * Plugin URI: https://wordpress.org/plugins/simple-seo/
    55 * Description: Change the page title and menu label output for any page or post, which can be useful for SEO (Search Engine Optimization) reasons. It may also increase the usability of your website, making it more friendly and understandable for your visitors.
    6  * Version: 1.4.0
     6 * Version: 1.5.0
    77 * Requires at least: 6.6
    88 * Requires PHP: 7.4
    … …  
    3535}
    3636
    37 define( 'SIMPLE_SEO_VERSION', '1.4.0' );
     37define( 'SIMPLE_SEO_VERSION', '1.5.0' );
    3838
    3939// This file must stay parseable by PHP 5.6: WordPress before 5.2 ignores "Requires PHP"
  • simple-seo/tags/1.5.0/src/admin.php

    r3715169 r3722856  
    4646
    4747/**
     48 * Help text for a post's share image.
     49 */
     50function share_image_help(): string {
     51        return __( 'Used in link previews instead of the featured image.', 'simple-seo' );
     52}
     53
     54/**
    4855 * Whether a post uses any of the fields. The Simple History tip only shows then.
    4956 *
    … …  
    5461                || '' !== get_description( $post_id )
    5562                || is_noindex( $post_id )
     63                || get_share_image_id( $post_id )
    5664                || '' !== get_menu_label( $post_id );
     65}
     66
     67/**
     68 * An image picker: a preview, the media library buttons and a hidden attachment ID, like core's
     69 * Site Icon. Used by the default share image in Settings and the share image in the Classic box.
     70 * Needs enqueue_image_field_script().
     71 *
     72 * @param string $name          Form field name.
     73 * @param int    $attachment_id The image, or 0.
     74 * @param string $preview_url   URL of the image to show, or ''.
     75 */
     76function image_field( string $name, int $attachment_id, string $preview_url ): void {
     77        $has_image = $attachment_id && '' !== $preview_url;
     78
     79        printf(
     80                '<div class="simple-seo-share-image"><img src="%1$s" alt="" style="max-width:300px;width:100%%;height:auto;display:block;margin-bottom:8px" %2$s />',
     81                esc_url( $preview_url ),
     82                $has_image ? '' : 'hidden'
     83        );
     84
     85        printf(
     86                '<input type="hidden" name="%1$s" value="%2$s" />',
     87                esc_attr( $name ),
     88                esc_attr( $has_image ? (string) $attachment_id : '' )
     89        );
     90
     91        printf(
     92                '<button type="button" class="button simple-seo-share-image-choose" data-choose="%1$s" data-change="%2$s">%3$s</button> ',
     93                esc_attr__( 'Choose image', 'simple-seo' ),
     94                esc_attr__( 'Change image', 'simple-seo' ),
     95                $has_image ? esc_html__( 'Change image', 'simple-seo' ) : esc_html__( 'Choose image', 'simple-seo' )
     96        );
     97
     98        printf(
     99                '<button type="button" class="button button-secondary simple-seo-share-image-remove" %1$s>%2$s</button></div>',
     100                $has_image ? '' : 'hidden',
     101                esc_html__( 'Remove image', 'simple-seo' )
     102        );
     103}
     104
     105/**
     106 * The media library and the script behind image_field(). Can be called while the page renders:
     107 * both print in the footer.
     108 */
     109function enqueue_image_field_script(): void {
     110        wp_enqueue_media();
     111
     112        $asset = require dirname( __DIR__ ) . '/build/share-image-field.asset.php';
     113
     114        wp_enqueue_script(
     115                'simple-seo-share-image-field',
     116                plugins_url( 'build/share-image-field.js', \SIMPLE_SEO_PLUGIN_FILE ),
     117                $asset['dependencies'],
     118                $asset['version'],
     119                true
     120        );
    57121}
    58122
    … …  
    91155        wp_add_inline_style(
    92156                'simple-seo-admin',
    93                 '#simple-seo label { display: block; margin-bottom: 4px; font-weight: 600; }
     157                '#simple-seo label, #simple-seo .simple-seo-field-label { display: block; margin-bottom: 4px; font-weight: 600; }
    94158                #simple-seo .description { display: block; margin-top: 4px; }
     159                #simple-seo .simple-seo-field { margin: 1em 0; }
    95160                .simple-seo-quick-edit .simple-seo-field, .simple-seo-quick-edit .simple-seo-check { display: block; margin-top: 6px; }
    96161                .simple-seo-quick-edit input[type="text"] { display: block; width: 100%; }
  • simple-seo/tags/1.5.0/src/class-simple-history-logger.php

    r3715169 r3722856  
    99namespace SimpleSEO;
    1010
     11use Simple_History\Event_Details\Event_Details_Container;
    1112use Simple_History\Event_Details\Event_Details_Group;
    1213use Simple_History\Event_Details\Event_Details_Group_Diff_Table_Formatter;
    … …  
    4344                return [
    4445                        'name'        => __( 'Simple SEO', 'simple-seo' ),
    45                         'description' => __( 'Logs changes to the SEO title, meta description, "Discourage search engines", menu label and default share image.', 'simple-seo' ),
     46                        'description' => __( 'Logs changes to the SEO title, meta description, "Discourage search engines", menu label, share image and default share image.', 'simple-seo' ),
    4647                        'name_via'    => __( 'Using plugin Simple SEO', 'simple-seo' ),
    4748                        'capability'  => 'edit_posts',
    … …  
    257258
    258259        /**
    259          * A before/after table of the fields that changed.
     260         * A before/after table of the fields that changed, and the share image side by side.
    260261         *
    261262         * @param object $row Log row.
    262          * @return Event_Details_Group
     263         * @return Event_Details_Group|Event_Details_Container
    263264         */
    264265        public function get_log_row_details_output( $row ) {
    265266                if ( 0 === strpos( (string) ( $row->context['_message_key'] ?? '' ), 'share_image_' ) ) {
    266                         return $this->share_image_details( $row->context );
     267                        return $this->share_image_details( $row->context, __( 'Default share image', 'simple-seo' ) );
    267268                }
    268269
    … …  
    278279                );
    279280
    280                 return $group;
     281                if ( ! array_key_exists( 'share_image_new', $row->context ) ) {
     282                        return $group;
     283                }
     284
     285                // The image diff is its own group, as Simple History's featured image. Older versions without
     286                // the container class get its URL as a row in the table.
     287                if ( ! class_exists( Event_Details_Container::class ) ) {
     288                        $group->add_items( [ new Event_Details_Item( [ 'share_image' ], __( 'Share image', 'simple-seo' ) ) ] );
     289                        return $group;
     290                }
     291
     292                return new Event_Details_Container( [ $group, $this->share_image_details( $row->context, __( 'Share image', 'simple-seo' ) ) ], $row->context );
    281293        }
    282294
    … …  
    285297         *
    286298         * @param array<string, mixed> $context Event context.
     299         * @param string               $label   Row label.
    287300         * @return Event_Details_Group
    288301         */
    289         private function share_image_details( array $context ) {
    290                 $item  = new Event_Details_Item( [ 'share_image' ], __( 'Default share image', 'simple-seo' ) );
     302        private function share_image_details( array $context, string $label ) {
     303                $item  = new Event_Details_Item( [ 'share_image' ], $label );
    291304                $group = new Event_Details_Group();
    292305
  • simple-seo/tags/1.5.0/src/classic-editor.php

    r3720725 r3722856  
    6969        }
    7070
     71        share_image_field_in_box( $post->ID );
     72
    7173        printf(
    7274                '<p><label><input type="checkbox" name="simple_seo[noindex_on]" value="1" aria-describedby="simple-seo-noindex-help" %1$s /> %2$s</label><span class="description" id="simple-seo-noindex-help">%3$s</span></p>',
    … …  
    9698                esc_html__( '(opens in a new tab)', 'simple-seo' )
    9799        );
     100}
     101
     102/**
     103 * The post's share image, for users who can use the media library (as core's Featured image).
     104 * Without the field the form posts no share_image, so saving leaves a stored image alone.
     105 *
     106 * @param int $post_id Post ID.
     107 */
     108function share_image_field_in_box( int $post_id ): void {
     109        if ( ! current_user_can( 'upload_files' ) ) {
     110                return;
     111        }
     112
     113        $share_image_id = get_share_image_id( $post_id );
     114
     115        // A group, not a <label>: that would replace the buttons' own names.
     116        printf(
     117                '<div class="simple-seo-field" role="group" aria-labelledby="simple-seo-share-image-label" aria-describedby="simple-seo-share-image-help"><span class="simple-seo-field-label" id="simple-seo-share-image-label">%s</span>',
     118                esc_html__( 'Share image', 'simple-seo' )
     119        );
     120        image_field( 'simple_seo[share_image]', $share_image_id, (string) wp_get_attachment_image_url( $share_image_id, 'medium' ) );
     121        printf( '<span class="description" id="simple-seo-share-image-help">%s</span></div>', esc_html( share_image_help() ) );
     122        enqueue_image_field_script();
    98123}
    99124
    … …  
    154179        update_post_meta( $post_id, NOINDEX_KEY, ! empty( $fields['noindex_on'] ) );
    155180
     181        // Quick Edit posts the other fields but not this one.
     182        if ( isset( $fields['share_image'] ) ) {
     183                update_post_meta( $post_id, SHARE_IMAGE_KEY, absint( $fields['share_image'] ) );
     184        }
     185
    156186        if ( 'page' === $post->post_type ) {
    157187                save_menu_label( $post_id, $text( 'menu_label' ) );
  • simple-seo/tags/1.5.0/src/link-previews.php

    r3720725 r3722856  
    7575                $title = '' !== $title ? $title : wp_strip_all_tags( get_the_title( $post_id ) );
    7676                $url   = (string) get_permalink( $post_id );
    77                 $image = featured_image( $post_id );
     77                $image = attachment_image( get_share_image_id( $post_id ) ) ?? attachment_image( (int) get_post_thumbnail_id( $post_id ) );
    7878
    7979                // No meta description: the hand-written excerpt, else the start of the content, so a
    … …  
    9898        /**
    9999         * The link preview image: [ 'url', 'width', 'height', 'alt' ], or null for none.
    100          * By default the featured image, else the default share image from Settings → General.
     100         * By default the post's share image, else its featured image, else the default share image from Settings → General.
    101101         *
    102          * @param array{url: string, width: int, height: int, alt: string}|null $image   The featured image, if any.
     102         * @param array{url: string, width: int, height: int, alt: string}|null $image   The image, if any.
    103103         * @param int                                                           $post_id The post, 0 on a front page with the latest posts.
    104104         */
    … …  
    157157
    158158/**
    159  * The post's featured image at full size, or null.
     159 * An image attachment at full size, or null when there is none (0, or deleted).
    160160 *
    161  * @param int $post_id Post ID.
     161 * @param int $attachment_id Attachment ID.
    162162 * @return array{url: string, width: int, height: int, alt: string}|null
    163163 */
    164 function featured_image( int $post_id ): ?array {
    165         $attachment_id = (int) get_post_thumbnail_id( $post_id );
    166         $src           = $attachment_id ? wp_get_attachment_image_src( $attachment_id, 'full' ) : false;
     164function attachment_image( int $attachment_id ): ?array {
     165        $src = $attachment_id ? wp_get_attachment_image_src( $attachment_id, 'full' ) : false;
    167166
    168167        if ( ! $src ) {
  • simple-seo/tags/1.5.0/src/meta.php

    r3714576 r3722856  
    1515const DESCRIPTION_KEY = '_simple_seo_description';
    1616const NOINDEX_KEY     = '_simple_seo_noindex';
     17
     18// Attachment ID of the image for link previews, used instead of the featured image.
     19const SHARE_IMAGE_KEY = '_simple_seo_share_image';
    1720
    1821// Menu label, pages only, Classic Editor only. Same keys as before 1.0: the label is used when the flag is 1.
    … …  
    2932add_action( 'deleted_post_meta', __NAMESPACE__ . '\\forget_legacy_title', 10, 3 );
    3033add_filter( 'default_post_metadata', __NAMESPACE__ . '\\legacy_title_default', 20, 4 );
     34add_action( 'added_post_meta', __NAMESPACE__ . '\\forget_empty_share_image', 10, 4 );
     35add_action( 'updated_post_meta', __NAMESPACE__ . '\\forget_empty_share_image', 10, 4 );
     36add_action( 'delete_attachment', __NAMESPACE__ . '\\forget_deleted_share_image' );
    3137
    3238/**
    … …  
    5965                );
    6066        }
     67
     68        register_post_meta(
     69                '',
     70                SHARE_IMAGE_KEY,
     71                [
     72                        'type'              => 'integer',
     73                        'description'       => __( 'Attachment ID of the image for link previews, used instead of the featured image. 0 or none uses the featured image.', 'simple-seo' ),
     74                        'single'            => true,
     75                        'default'           => 0,
     76                        'show_in_rest'      => [ 'schema' => [ 'context' => [ 'edit' ] ] ],
     77                        'sanitize_callback' => __NAMESPACE__ . '\\sanitize_share_image_id',
     78                        'auth_callback'     => fn( $allowed, $meta_key, $post_id ) => current_user_can( 'edit_post', $post_id ),
     79                ]
     80        );
     81}
     82
     83/**
     84 * The share image must be an image attachment; anything else is stored as 0, which is then deleted.
     85 *
     86 * @param mixed $value Attachment ID.
     87 */
     88function sanitize_share_image_id( $value ): int {
     89        $attachment_id = absint( $value );
     90
     91        return $attachment_id && wp_attachment_is_image( $attachment_id ) ? $attachment_id : 0;
     92}
     93
     94/**
     95 * No share image (0, as the editors send on Remove) deletes the key, as an empty text field does.
     96 *
     97 * @param int    $meta_id    Meta ID, unused.
     98 * @param int    $object_id  Post ID.
     99 * @param string $meta_key   Meta key.
     100 * @param mixed  $meta_value The stored value.
     101 */
     102function forget_empty_share_image( $meta_id, $object_id, $meta_key, $meta_value ): void {
     103        if ( SHARE_IMAGE_KEY === $meta_key && ! absint( $meta_value ) ) {
     104                delete_post_meta( $object_id, SHARE_IMAGE_KEY );
     105        }
    61106}
    62107
    … …  
    105150function get_description( int $post_id ): string {
    106151        return trim( (string) get_post_meta( $post_id, DESCRIPTION_KEY, true ) );
     152}
     153
     154/**
     155 * A deleted image is no longer anyone's share image, so the editors don't show an empty field.
     156 * One bulk delete for all posts (core's $delete_all), only when an attachment is deleted: one
     157 * query when no post uses the image, three when some do.
     158 *
     159 * @param int $attachment_id Attachment ID.
     160 */
     161function forget_deleted_share_image( $attachment_id ): void {
     162        delete_metadata( 'post', 0, SHARE_IMAGE_KEY, (int) $attachment_id, true );
     163}
     164
     165/**
     166 * The post's own share image (attachment ID), or 0 to use the featured image.
     167 *
     168 * @param int $post_id Post ID.
     169 */
     170function get_share_image_id( int $post_id ): int {
     171        return absint( get_post_meta( $post_id, SHARE_IMAGE_KEY, true ) );
    107172}
    108173
  • simple-seo/tags/1.5.0/src/settings.php

    r3720725 r3722856  
    7474        $image = share_image();
    7575
    76         printf(
    77                 '<div class="simple-seo-share-image"><img src="%1$s" alt="" style="max-width:300px;height:auto;display:block;margin-bottom:8px" %2$s />',
    78                 esc_url( $image['url'] ?? '' ),
    79                 $image ? '' : 'hidden'
    80         );
    81 
    82         printf(
    83                 '<input type="hidden" name="%1$s" value="%2$s" />',
    84                 esc_attr( SHARE_IMAGE_OPTION ),
    85                 esc_attr( (string) ( $image['id'] ?? '' ) )
    86         );
    87 
    88         printf(
    89                 '<button type="button" class="button simple-seo-share-image-choose" data-choose="%1$s" data-change="%2$s">%3$s</button> ',
    90                 esc_attr__( 'Choose image', 'simple-seo' ),
    91                 esc_attr__( 'Change image', 'simple-seo' ),
    92                 $image ? esc_html__( 'Change image', 'simple-seo' ) : esc_html__( 'Choose image', 'simple-seo' )
    93         );
    94 
    95         printf(
    96                 '<button type="button" class="button button-secondary simple-seo-share-image-remove" %1$s>%2$s</button></div>',
    97                 $image ? '' : 'hidden',
    98                 esc_html__( 'Remove image', 'simple-seo' )
    99         );
     76        image_field( SHARE_IMAGE_OPTION, (int) ( $image['id'] ?? 0 ), (string) ( $image['url'] ?? '' ) );
    10077
    10178        printf(
    … …  
    11592        }
    11693
    117         wp_enqueue_media();
    118 
    119         $asset = require dirname( __DIR__ ) . '/build/share-image-field.asset.php';
    120 
    121         wp_enqueue_script(
    122                 'simple-seo-share-image-field',
    123                 plugins_url( 'build/share-image-field.js', \SIMPLE_SEO_PLUGIN_FILE ),
    124                 $asset['dependencies'],
    125                 $asset['version'],
    126                 true
    127         );
     94        enqueue_image_field_script();
    12895}
    12996
  • simple-seo/tags/1.5.0/src/simple-history.php

    r3714576 r3722856  
    5757                'noindex'          => is_noindex( $post_id ) ? __( 'Yes', 'simple-seo' ) : __( 'No', 'simple-seo' ),
    5858                'menu_label'       => get_menu_label( $post_id ),
     59                'share_image'      => (string) wp_get_attachment_url( get_share_image_id( $post_id ) ),
    5960        ];
    6061}
  • simple-seo/trunk/build/editor-panel.asset.php

    r3720725 r3722856  
    22        'dependencies' => array(
    33                'react-jsx-runtime',
     4                'wp-block-editor',
    45                'wp-components',
    56                'wp-core-data',
    … …  
    1011                'wp-plugins'
    1112        ),
    12         'version' => '15619d0c8ed75730152f'
     13        'version' => '34f3d3968f2319195d78'
    1314);
  • simple-seo/trunk/build/editor-panel.js

    r3720725 r3722856  
    1 (()=>{"use strict";const e=window.wp.plugins,s=window.wp.editor,i=window.wp.coreData,t=window.wp.data,o=window.wp.element,n=window.wp.components,l=window.wp.i18n,r=window.ReactJSXRuntime,{otherPlugin:a,simpleHistoryUrl:p,frontPageId:_,faqUrl:m}=window.simpleSeoEditor||{};(0,e.registerPlugin)("simple-seo",{render:function(){const e=(0,t.useSelect)(e=>e(s.store).getCurrentPostType(),[]),d=(0,t.useSelect)(e=>e(s.store).getCurrentPostId(),[]),[c,h]=(0,i.useEntityProp)("postType",e,"meta");if(!c)return null;const w=""!==c._simple_seo_title.trim()||""!==c._simple_seo_description.trim()||!!c._simple_seo_noindex,u=e=>s=>h({[e]:s});return(0,r.jsx)(s.PluginDocumentSettingPanel,{name:"simple-seo",title:(0,l.__)("Simple SEO","simple-seo"),children:(0,r.jsxs)(n.Flex,{direction:"column",gap:4,children:[a&&(0,r.jsx)(n.Notice,{status:"warning",isDismissible:!1,children:(0,l.sprintf)(/* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */ /* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */
    2 (0,l.__)("%s plugin is active and handles SEO, so these fields aren’t used.","simple-seo"),a)}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow simple-seo-title",rows:1,label:(0,l.__)("SEO title","simple-seo"),help:d===_?(0,l.__)("The whole title of the front page.","simple-seo"):(0,l.__)("About 50 characters. The site name is added after it.","simple-seo"),value:c._simple_seo_title,onChange:e=>u("_simple_seo_title")(e.replace(/[\r\n]+/g," ")),onKeyDown:e=>{"Enter"===e.key&&e.preventDefault()}}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow",rows:2,label:(0,l.__)("Meta description","simple-seo"),help:(0,l.__)("Often shown in search results.","simple-seo"),value:c._simple_seo_description,onChange:u("_simple_seo_description")}),(0,r.jsx)(n.CheckboxControl,{__nextHasNoMarginBottom:!0,label:(0,l.__)("Discourage search engines from indexing this page","simple-seo"),help:(0,l.__)("It’s up to search engines to honor this request.","simple-seo"),checked:!!c._simple_seo_noindex,onChange:e=>h({_simple_seo_noindex:e})}),p&&w&&(0,r.jsx)("p",{className:"simple-seo-tip",children:(0,o.createInterpolateElement)((0,l.__)("Tip: <a>Simple History</a> logs every change to these fields.","simple-seo"),{a:(0,r.jsx)("a",{href:p})})}),(0,r.jsx)("div",{children:(0,r.jsx)(n.ExternalLink,{href:m,children:(0,l.__)("Learn more about these fields","simple-seo")})})]})})}})})();
     1(()=>{"use strict";const e=window.wp.plugins,s=window.wp.editor,i=window.wp.blockEditor,t=window.wp.coreData,o=window.wp.data,l=window.wp.element,n=window.wp.components,a=window.wp.i18n,r=window.ReactJSXRuntime,{otherPlugin:_,simpleHistoryUrl:p,frontPageId:m,faqUrl:d}=window.simpleSeoEditor||{};function c({imageId:e,onChange:s}){const l=(0,o.useSelect)(s=>e?s(t.store).getMedia(e):void 0,[e]),_=l?.media_details?.sizes?.medium?.source_url??l?.source_url;return(0,r.jsx)(i.MediaUploadCheck,{children:(0,r.jsxs)(n.BaseControl,{__nextHasNoMarginBottom:!0,id:"simple-seo-share-image",help:(0,a.__)("Used in link previews instead of the featured image.","simple-seo"),children:[(0,r.jsx)(n.BaseControl.VisualLabel,{children:(0,a.__)("Share image","simple-seo")}),(0,r.jsx)(i.MediaUpload,{title:(0,a.__)("Share image","simple-seo"),allowedTypes:["image"],value:e,onSelect:e=>s(e.id),render:({open:i})=>(0,r.jsxs)(n.Flex,{direction:"column",gap:2,align:"flex-start",children:[_&&(0,r.jsx)("img",{src:_,alt:l?.alt_text??"",style:{maxWidth:"100%",height:"auto"}}),(0,r.jsxs)(n.Flex,{justify:"flex-start",gap:2,children:[(0,r.jsx)(n.Button,{__next40pxDefaultSize:!0,id:"simple-seo-share-image","aria-describedby":"simple-seo-share-image__help",variant:"secondary",onClick:i,children:e?(0,a.__)("Replace","simple-seo"):(0,a.__)("Set share image","simple-seo")}),!!e&&(0,r.jsx)(n.Button,{__next40pxDefaultSize:!0,variant:"tertiary",isDestructive:!0,onClick:()=>s(0),children:(0,a.__)("Remove","simple-seo")})]})]})})]})})}(0,e.registerPlugin)("simple-seo",{render:function(){const e=(0,o.useSelect)(e=>e(s.store).getCurrentPostType(),[]),i=(0,o.useSelect)(e=>e(s.store).getCurrentPostId(),[]),[h,g]=(0,t.useEntityProp)("postType",e,"meta");if(!h)return null;const u=""!==h._simple_seo_title.trim()||""!==h._simple_seo_description.trim()||!!h._simple_seo_noindex||!!h._simple_seo_share_image,x=e=>s=>g({[e]:s});return(0,r.jsx)(s.PluginDocumentSettingPanel,{name:"simple-seo",title:(0,a.__)("Simple SEO","simple-seo"),children:(0,r.jsxs)(n.Flex,{direction:"column",gap:4,children:[_&&(0,r.jsx)(n.Notice,{status:"warning",isDismissible:!1,children:(0,a.sprintf)(/* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */ /* translators: %s: name of another SEO plugin, like "Yoast SEO" or "The SEO Framework". */
     2(0,a.__)("%s plugin is active and handles SEO, so these fields aren’t used.","simple-seo"),_)}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow simple-seo-title",rows:1,label:(0,a.__)("SEO title","simple-seo"),help:i===m?(0,a.__)("The whole title of the front page.","simple-seo"):(0,a.__)("About 50 characters. The site name is added after it.","simple-seo"),value:h._simple_seo_title,onChange:e=>x("_simple_seo_title")(e.replace(/[\r\n]+/g," ")),onKeyDown:e=>{"Enter"===e.key&&e.preventDefault()}}),(0,r.jsx)(n.TextareaControl,{__nextHasNoMarginBottom:!0,className:"simple-seo-autogrow",rows:2,label:(0,a.__)("Meta description","simple-seo"),help:(0,a.__)("Often shown in search results.","simple-seo"),value:h._simple_seo_description,onChange:x("_simple_seo_description")}),(0,r.jsx)(c,{imageId:h._simple_seo_share_image??0,onChange:x("_simple_seo_share_image")}),(0,r.jsx)(n.CheckboxControl,{__nextHasNoMarginBottom:!0,label:(0,a.__)("Discourage search engines from indexing this page","simple-seo"),help:(0,a.__)("It’s up to search engines to honor this request.","simple-seo"),checked:!!h._simple_seo_noindex,onChange:e=>g({_simple_seo_noindex:e})}),p&&u&&(0,r.jsx)("p",{className:"simple-seo-tip",children:(0,l.createInterpolateElement)((0,a.__)("Tip: <a>Simple History</a> logs every change to these fields.","simple-seo"),{a:(0,r.jsx)("a",{href:p})})}),(0,r.jsx)("div",{children:(0,r.jsx)(n.ExternalLink,{href:d,children:(0,a.__)("Learn more about these fields","simple-seo")})})]})})}})})();
  • simple-seo/trunk/js/editor-panel.js

    r3720725 r3722856  
    1111        store as editorStore,
    1212} from '@wordpress/editor';
    13 import { useEntityProp } from '@wordpress/core-data';
     13import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
     14import { store as coreStore, useEntityProp } from '@wordpress/core-data';
    1415import { useSelect } from '@wordpress/data';
    1516import { createInterpolateElement } from '@wordpress/element';
    1617import {
     18        BaseControl,
     19        Button,
    1720        CheckboxControl,
    1821        ExternalLink,
    … …  
    2629        window.simpleSeoEditor || {};
    2730
     31/**
     32 * The post's share image: a preview and the media library buttons, like core's
     33 * Featured image panel. Shows nothing for users who can't upload files.
     34 *
     35 * @param {Object}               props
     36 * @param {number}               props.imageId  Attachment ID, or 0.
     37 * @param {(id: number) => void} props.onChange Called with the new attachment ID, 0 to remove it.
     38 */
     39function ShareImageControl( { imageId, onChange } ) {
     40        const media = useSelect(
     41                ( select ) =>
     42                        imageId ? select( coreStore ).getMedia( imageId ) : undefined,
     43                [ imageId ]
     44        );
     45        const previewUrl =
     46                media?.media_details?.sizes?.medium?.source_url ?? media?.source_url;
     47
     48        return (
     49                <MediaUploadCheck>
     50                        { /* A visual label, not a <label>: that would replace the button's own name. */ }
     51                        <BaseControl
     52                                __nextHasNoMarginBottom
     53                                id="simple-seo-share-image"
     54                                help={ __(
     55                                        'Used in link previews instead of the featured image.',
     56                                        'simple-seo'
     57                                ) }
     58                        >
     59                                <BaseControl.VisualLabel>
     60                                        { __( 'Share image', 'simple-seo' ) }
     61                                </BaseControl.VisualLabel>
     62                                <MediaUpload
     63                                        title={ __( 'Share image', 'simple-seo' ) }
     64                                        allowedTypes={ [ 'image' ] }
     65                                        value={ imageId }
     66                                        onSelect={ ( image ) => onChange( image.id ) }
     67                                        render={ ( { open } ) => (
     68                                                <Flex direction="column" gap={ 2 } align="flex-start">
     69                                                        { previewUrl && (
     70                                                                <img
     71                                                                        src={ previewUrl }
     72                                                                        alt={ media?.alt_text ?? '' }
     73                                                                        style={ {
     74                                                                                maxWidth: '100%',
     75                                                                                height: 'auto',
     76                                                                        } }
     77                                                                />
     78                                                        ) }
     79                                                        <Flex justify="flex-start" gap={ 2 }>
     80                                                                <Button
     81                                                                        __next40pxDefaultSize
     82                                                                        id="simple-seo-share-image"
     83                                                                        aria-describedby="simple-seo-share-image__help"
     84                                                                        variant="secondary"
     85                                                                        onClick={ open }
     86                                                                >
     87                                                                        { imageId
     88                                                                                ? __( 'Replace', 'simple-seo' )
     89                                                                                : __(
     90                                                                                                'Set share image',
     91                                                                                                'simple-seo'
     92                                                                                        ) }
     93                                                                </Button>
     94                                                                { !! imageId && (
     95                                                                        <Button
     96                                                                                __next40pxDefaultSize
     97                                                                                variant="tertiary"
     98                                                                                isDestructive
     99                                                                                onClick={ () => onChange( 0 ) }
     100                                                                        >
     101                                                                                { __( 'Remove', 'simple-seo' ) }
     102                                                                        </Button>
     103                                                                ) }
     104                                                        </Flex>
     105                                                </Flex>
     106                                        ) }
     107                                />
     108                        </BaseControl>
     109                </MediaUploadCheck>
     110        );
     111}
     112
    28113function SimpleSeoPanel() {
    29114        const postType = useSelect(
    … …  
    46131                meta._simple_seo_title.trim() !== '' ||
    47132                meta._simple_seo_description.trim() !== '' ||
    48                 !! meta._simple_seo_noindex;
     133                !! meta._simple_seo_noindex ||
     134                !! meta._simple_seo_share_image;
    49135
    50136        // Meta edits are merged, so pass only the changed key.
    … …  
    118204                                />
    119205
     206                                <ShareImageControl
     207                                        imageId={ meta._simple_seo_share_image ?? 0 }
     208                                        onChange={ update( '_simple_seo_share_image' ) }
     209                                />
     210
    120211                                <CheckboxControl
    121212                                        __nextHasNoMarginBottom
  • simple-seo/trunk/readme.txt

    r3720763 r3722856  
    66Tested up to: 7.1
    77Requires PHP: 7.4
    8 Stable tag: 1.4.0
     8Stable tag: 1.5.0
    99License: GPLv2
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1111
    12 The SEO basics and nothing more: a title, a description and a "don't index" checkbox for every page. No upsells, no settings maze.
     12The SEO basics: a title, a description and a "don't index" checkbox for every page. Small, fast, and no upsell banners all over your admin.
    1313
    1414== Description ==
    … …  
    2424A few things just happen: link previews with the right title, description and image in Slack, Mastodon, Bluesky and friends. Pages you keep out of search are left out of the sitemap too. There's a panel in the block editor, a box in the Classic Editor, and the fields in Quick Edit. Using Yoast SEO or another big SEO plugin? Simple SEO steps aside.
    2525
    26 No settings page, no scores, no "Go Pro" banners, no extra database queries. <del>No nags.</del> Okay, one small grey tip about [Simple History](https://wordpress.org/plugins/simple-history/), my activity log plugin.
     26No scores, no "Go Pro" banners, no extra database queries. <del>No nags.</del> Okay, one small grey tip about [Simple History](https://wordpress.org/plugins/simple-history/), my activity log plugin.
    2727
    2828No plugin makes a page rank. Good content does. Simple SEO hands search engines and shared links the title, description and image you chose, and asks search engines to skip the pages you don't want listed. That's also all AI search needs from a plugin: no llms.txt, no "AI optimization".
    … …  
    3232Simple SEO is old. Like really old. It arrived on WordPress.org in August 2010 (seven weeks before Yoast SEO!). Of the SEO plugins still maintained today, only All in One SEO is older. Then it slept from 2012 to 2026.
    3333
    34 I woke it up mostly for myself: I wanted the SEO basics on my own sites without a plugin that asks for attention every time I log in. It stays small on purpose, so feature requests often get "that's a bit much for a plugin called Simple".
     34I woke it up mostly for myself: I wanted the SEO basics on my own sites without a plugin that asks for attention every time I log in. It grows when I need something on my own sites, but it stays small and fast on purpose.
    3535
    3636No big company or investors, just one developer: me, the guy behind [Simple History](https://simple-history.com/). With both active, every SEO change is logged: who, when, and what it said before. I also make [CMS Tree Page View](https://wordpress.org/plugins/cms-tree-page-view/), and the two work together too. If Simple SEO saves you time, [a donation](https://eskapism.se/sida/donate/) keeps it going.
    … …  
    6060A wide picture, 1200 × 630 pixels, not your logo. It's used in link previews when a post has no featured image.
    6161
     62= Can a post have a different image in link previews? =
     63
     64Yes, pick a share image in the post's Simple SEO fields. Link previews use it instead of the featured image, and your theme keeps showing the featured image. Handy when the picture that looks good on your site isn't the one that works in a shared link, like a card with the headline on it.
     65
    6266= I use Yoast SEO (or Rank Math, All in One SEO, SEOPress, The SEO Framework). =
    6367
    … …  
    7074= Can I use WP-CLI, the REST API or filters? =
    7175
    72 Yup. The fields are post meta: `_simple_seo_title`, `_simple_seo_description` and `_simple_seo_noindex`, in the REST API with `?context=edit`. Everything Simple SEO outputs goes through a filter first. See [the hooks](https://github.com/bonny/WordPress-Simple-SEO/blob/main/docs/hooks.md).
     76Yup. The fields are post meta: `_simple_seo_title`, `_simple_seo_description`, `_simple_seo_noindex` and `_simple_seo_share_image` (an attachment ID), in the REST API with `?context=edit`. Everything Simple SEO outputs goes through a filter first. See [the hooks](https://github.com/bonny/WordPress-Simple-SEO/blob/main/docs/hooks.md).
    7377
    7478= What happened to the menu label? =
    … …  
    7882== Screenshots ==
    7983
    80 1. A few SEO fields in the page sidebar of the block editor. That's it.
     841. The SEO fields in the page sidebar of the block editor.
    81852. See and edit the SEO of every page right from the Pages list, with Quick Edit.
    82863. The same column for posts, so you can spot the ones missing a description.
    83874. Works in the Classic Editor too.
    84 5. One setting: a default share image for link previews, in Settings → General.
     885. A default share image for link previews, in Settings → General.
    85896. With Simple History, every SEO change is logged: who, when, and what it said before.
    8690
    8791== Changelog ==
     92
     93= 1.5.0 (October 2026) =
     94- New: a share image for each post, used in link previews instead of the featured image. Your theme keeps showing the featured image. In both editors, and logged in Simple History.
    8895
    8996= 1.4.0 (September 2026) =
  • simple-seo/trunk/simple-seo.php

    r3720725 r3722856  
    44 * Plugin URI: https://wordpress.org/plugins/simple-seo/
    55 * Description: Change the page title and menu label output for any page or post, which can be useful for SEO (Search Engine Optimization) reasons. It may also increase the usability of your website, making it more friendly and understandable for your visitors.
    6  * Version: 1.4.0
     6 * Version: 1.5.0
    77 * Requires at least: 6.6
    88 * Requires PHP: 7.4
    … …  
    3535}
    3636
    37 define( 'SIMPLE_SEO_VERSION', '1.4.0' );
     37define( 'SIMPLE_SEO_VERSION', '1.5.0' );
    3838
    3939// This file must stay parseable by PHP 5.6: WordPress before 5.2 ignores "Requires PHP"
  • simple-seo/trunk/src/admin.php

    r3715169 r3722856  
    4646
    4747/**
     48 * Help text for a post's share image.
     49 */
     50function share_image_help(): string {
     51        return __( 'Used in link previews instead of the featured image.', 'simple-seo' );
     52}
     53
     54/**
    4855 * Whether a post uses any of the fields. The Simple History tip only shows then.
    4956 *
    … …  
    5461                || '' !== get_description( $post_id )
    5562                || is_noindex( $post_id )
     63                || get_share_image_id( $post_id )
    5664                || '' !== get_menu_label( $post_id );
     65}
     66
     67/**
     68 * An image picker: a preview, the media library buttons and a hidden attachment ID, like core's
     69 * Site Icon. Used by the default share image in Settings and the share image in the Classic box.
     70 * Needs enqueue_image_field_script().
     71 *
     72 * @param string $name          Form field name.
     73 * @param int    $attachment_id The image, or 0.
     74 * @param string $preview_url   URL of the image to show, or ''.
     75 */
     76function image_field( string $name, int $attachment_id, string $preview_url ): void {
     77        $has_image = $attachment_id && '' !== $preview_url;
     78
     79        printf(
     80                '<div class="simple-seo-share-image"><img src="%1$s" alt="" style="max-width:300px;width:100%%;height:auto;display:block;margin-bottom:8px" %2$s />',
     81                esc_url( $preview_url ),
     82                $has_image ? '' : 'hidden'
     83        );
     84
     85        printf(
     86                '<input type="hidden" name="%1$s" value="%2$s" />',
     87                esc_attr( $name ),
     88                esc_attr( $has_image ? (string) $attachment_id : '' )
     89        );
     90
     91        printf(
     92                '<button type="button" class="button simple-seo-share-image-choose" data-choose="%1$s" data-change="%2$s">%3$s</button> ',
     93                esc_attr__( 'Choose image', 'simple-seo' ),
     94                esc_attr__( 'Change image', 'simple-seo' ),
     95                $has_image ? esc_html__( 'Change image', 'simple-seo' ) : esc_html__( 'Choose image', 'simple-seo' )
     96        );
     97
     98        printf(
     99                '<button type="button" class="button button-secondary simple-seo-share-image-remove" %1$s>%2$s</button></div>',
     100                $has_image ? '' : 'hidden',
     101                esc_html__( 'Remove image', 'simple-seo' )
     102        );
     103}
     104
     105/**
     106 * The media library and the script behind image_field(). Can be called while the page renders:
     107 * both print in the footer.
     108 */
     109function enqueue_image_field_script(): void {
     110        wp_enqueue_media();
     111
     112        $asset = require dirname( __DIR__ ) . '/build/share-image-field.asset.php';
     113
     114        wp_enqueue_script(
     115                'simple-seo-share-image-field',
     116                plugins_url( 'build/share-image-field.js', \SIMPLE_SEO_PLUGIN_FILE ),
     117                $asset['dependencies'],
     118                $asset['version'],
     119                true
     120        );
    57121}
    58122
    … …  
    91155        wp_add_inline_style(
    92156                'simple-seo-admin',
    93                 '#simple-seo label { display: block; margin-bottom: 4px; font-weight: 600; }
     157                '#simple-seo label, #simple-seo .simple-seo-field-label { display: block; margin-bottom: 4px; font-weight: 600; }
    94158                #simple-seo .description { display: block; margin-top: 4px; }
     159                #simple-seo .simple-seo-field { margin: 1em 0; }
    95160                .simple-seo-quick-edit .simple-seo-field, .simple-seo-quick-edit .simple-seo-check { display: block; margin-top: 6px; }
    96161                .simple-seo-quick-edit input[type="text"] { display: block; width: 100%; }
  • simple-seo/trunk/src/class-simple-history-logger.php

    r3715169 r3722856  
    99namespace SimpleSEO;
    1010
     11use Simple_History\Event_Details\Event_Details_Container;
    1112use Simple_History\Event_Details\Event_Details_Group;
    1213use Simple_History\Event_Details\Event_Details_Group_Diff_Table_Formatter;
    … …  
    4344                return [
    4445                        'name'        => __( 'Simple SEO', 'simple-seo' ),
    45                         'description' => __( 'Logs changes to the SEO title, meta description, "Discourage search engines", menu label and default share image.', 'simple-seo' ),
     46                        'description' => __( 'Logs changes to the SEO title, meta description, "Discourage search engines", menu label, share image and default share image.', 'simple-seo' ),
    4647                        'name_via'    => __( 'Using plugin Simple SEO', 'simple-seo' ),
    4748                        'capability'  => 'edit_posts',
    … …  
    257258
    258259        /**
    259          * A before/after table of the fields that changed.
     260         * A before/after table of the fields that changed, and the share image side by side.
    260261         *
    261262         * @param object $row Log row.
    262          * @return Event_Details_Group
     263         * @return Event_Details_Group|Event_Details_Container
    263264         */
    264265        public function get_log_row_details_output( $row ) {
    265266                if ( 0 === strpos( (string) ( $row->context['_message_key'] ?? '' ), 'share_image_' ) ) {
    266                         return $this->share_image_details( $row->context );
     267                        return $this->share_image_details( $row->context, __( 'Default share image', 'simple-seo' ) );
    267268                }
    268269
    … …  
    278279                );
    279280
    280                 return $group;
     281                if ( ! array_key_exists( 'share_image_new', $row->context ) ) {
     282                        return $group;
     283                }
     284
     285                // The image diff is its own group, as Simple History's featured image. Older versions without
     286                // the container class get its URL as a row in the table.
     287                if ( ! class_exists( Event_Details_Container::class ) ) {
     288                        $group->add_items( [ new Event_Details_Item( [ 'share_image' ], __( 'Share image', 'simple-seo' ) ) ] );
     289                        return $group;
     290                }
     291
     292                return new Event_Details_Container( [ $group, $this->share_image_details( $row->context, __( 'Share image', 'simple-seo' ) ) ], $row->context );
    281293        }
    282294
    … …  
    285297         *
    286298         * @param array<string, mixed> $context Event context.
     299         * @param string               $label   Row label.
    287300         * @return Event_Details_Group
    288301         */
    289         private function share_image_details( array $context ) {
    290                 $item  = new Event_Details_Item( [ 'share_image' ], __( 'Default share image', 'simple-seo' ) );
     302        private function share_image_details( array $context, string $label ) {
     303                $item  = new Event_Details_Item( [ 'share_image' ], $label );
    291304                $group = new Event_Details_Group();
    292305
  • simple-seo/trunk/src/classic-editor.php

    r3720725 r3722856  
    6969        }
    7070
     71        share_image_field_in_box( $post->ID );
     72
    7173        printf(
    7274                '<p><label><input type="checkbox" name="simple_seo[noindex_on]" value="1" aria-describedby="simple-seo-noindex-help" %1$s /> %2$s</label><span class="description" id="simple-seo-noindex-help">%3$s</span></p>',
    … …  
    9698                esc_html__( '(opens in a new tab)', 'simple-seo' )
    9799        );
     100}
     101
     102/**
     103 * The post's share image, for users who can use the media library (as core's Featured image).
     104 * Without the field the form posts no share_image, so saving leaves a stored image alone.
     105 *
     106 * @param int $post_id Post ID.
     107 */
     108function share_image_field_in_box( int $post_id ): void {
     109        if ( ! current_user_can( 'upload_files' ) ) {
     110                return;
     111        }
     112
     113        $share_image_id = get_share_image_id( $post_id );
     114
     115        // A group, not a <label>: that would replace the buttons' own names.
     116        printf(
     117                '<div class="simple-seo-field" role="group" aria-labelledby="simple-seo-share-image-label" aria-describedby="simple-seo-share-image-help"><span class="simple-seo-field-label" id="simple-seo-share-image-label">%s</span>',
     118                esc_html__( 'Share image', 'simple-seo' )
     119        );
     120        image_field( 'simple_seo[share_image]', $share_image_id, (string) wp_get_attachment_image_url( $share_image_id, 'medium' ) );
     121        printf( '<span class="description" id="simple-seo-share-image-help">%s</span></div>', esc_html( share_image_help() ) );
     122        enqueue_image_field_script();
    98123}
    99124
    … …  
    154179        update_post_meta( $post_id, NOINDEX_KEY, ! empty( $fields['noindex_on'] ) );
    155180
     181        // Quick Edit posts the other fields but not this one.
     182        if ( isset( $fields['share_image'] ) ) {
     183                update_post_meta( $post_id, SHARE_IMAGE_KEY, absint( $fields['share_image'] ) );
     184        }
     185
    156186        if ( 'page' === $post->post_type ) {
    157187                save_menu_label( $post_id, $text( 'menu_label' ) );
  • simple-seo/trunk/src/link-previews.php

    r3720725 r3722856  
    7575                $title = '' !== $title ? $title : wp_strip_all_tags( get_the_title( $post_id ) );
    7676                $url   = (string) get_permalink( $post_id );
    77                 $image = featured_image( $post_id );
     77                $image = attachment_image( get_share_image_id( $post_id ) ) ?? attachment_image( (int) get_post_thumbnail_id( $post_id ) );
    7878
    7979                // No meta description: the hand-written excerpt, else the start of the content, so a
    … …  
    9898        /**
    9999         * The link preview image: [ 'url', 'width', 'height', 'alt' ], or null for none.
    100          * By default the featured image, else the default share image from Settings → General.
     100         * By default the post's share image, else its featured image, else the default share image from Settings → General.
    101101         *
    102          * @param array{url: string, width: int, height: int, alt: string}|null $image   The featured image, if any.
     102         * @param array{url: string, width: int, height: int, alt: string}|null $image   The image, if any.
    103103         * @param int                                                           $post_id The post, 0 on a front page with the latest posts.
    104104         */
    … …  
    157157
    158158/**
    159  * The post's featured image at full size, or null.
     159 * An image attachment at full size, or null when there is none (0, or deleted).
    160160 *
    161  * @param int $post_id Post ID.
     161 * @param int $attachment_id Attachment ID.
    162162 * @return array{url: string, width: int, height: int, alt: string}|null
    163163 */
    164 function featured_image( int $post_id ): ?array {
    165         $attachment_id = (int) get_post_thumbnail_id( $post_id );
    166         $src           = $attachment_id ? wp_get_attachment_image_src( $attachment_id, 'full' ) : false;
     164function attachment_image( int $attachment_id ): ?array {
     165        $src = $attachment_id ? wp_get_attachment_image_src( $attachment_id, 'full' ) : false;
    167166
    168167        if ( ! $src ) {
  • simple-seo/trunk/src/meta.php

    r3714576 r3722856  
    1515const DESCRIPTION_KEY = '_simple_seo_description';
    1616const NOINDEX_KEY     = '_simple_seo_noindex';
     17
     18// Attachment ID of the image for link previews, used instead of the featured image.
     19const SHARE_IMAGE_KEY = '_simple_seo_share_image';
    1720
    1821// Menu label, pages only, Classic Editor only. Same keys as before 1.0: the label is used when the flag is 1.
    … …  
    2932add_action( 'deleted_post_meta', __NAMESPACE__ . '\\forget_legacy_title', 10, 3 );
    3033add_filter( 'default_post_metadata', __NAMESPACE__ . '\\legacy_title_default', 20, 4 );
     34add_action( 'added_post_meta', __NAMESPACE__ . '\\forget_empty_share_image', 10, 4 );
     35add_action( 'updated_post_meta', __NAMESPACE__ . '\\forget_empty_share_image', 10, 4 );
     36add_action( 'delete_attachment', __NAMESPACE__ . '\\forget_deleted_share_image' );
    3137
    3238/**
    … …  
    5965                );
    6066        }
     67
     68        register_post_meta(
     69                '',
     70                SHARE_IMAGE_KEY,
     71                [
     72                        'type'              => 'integer',
     73                        'description'       => __( 'Attachment ID of the image for link previews, used instead of the featured image. 0 or none uses the featured image.', 'simple-seo' ),
     74                        'single'            => true,
     75                        'default'           => 0,
     76                        'show_in_rest'      => [ 'schema' => [ 'context' => [ 'edit' ] ] ],
     77                        'sanitize_callback' => __NAMESPACE__ . '\\sanitize_share_image_id',
     78                        'auth_callback'     => fn( $allowed, $meta_key, $post_id ) => current_user_can( 'edit_post', $post_id ),
     79                ]
     80        );
     81}
     82
     83/**
     84 * The share image must be an image attachment; anything else is stored as 0, which is then deleted.
     85 *
     86 * @param mixed $value Attachment ID.
     87 */
     88function sanitize_share_image_id( $value ): int {
     89        $attachment_id = absint( $value );
     90
     91        return $attachment_id && wp_attachment_is_image( $attachment_id ) ? $attachment_id : 0;
     92}
     93
     94/**
     95 * No share image (0, as the editors send on Remove) deletes the key, as an empty text field does.
     96 *
     97 * @param int    $meta_id    Meta ID, unused.
     98 * @param int    $object_id  Post ID.
     99 * @param string $meta_key   Meta key.
     100 * @param mixed  $meta_value The stored value.
     101 */
     102function forget_empty_share_image( $meta_id, $object_id, $meta_key, $meta_value ): void {
     103        if ( SHARE_IMAGE_KEY === $meta_key && ! absint( $meta_value ) ) {
     104                delete_post_meta( $object_id, SHARE_IMAGE_KEY );
     105        }
    61106}
    62107
    … …  
    105150function get_description( int $post_id ): string {
    106151        return trim( (string) get_post_meta( $post_id, DESCRIPTION_KEY, true ) );
     152}
     153
     154/**
     155 * A deleted image is no longer anyone's share image, so the editors don't show an empty field.
     156 * One bulk delete for all posts (core's $delete_all), only when an attachment is deleted: one
     157 * query when no post uses the image, three when some do.
     158 *
     159 * @param int $attachment_id Attachment ID.
     160 */
     161function forget_deleted_share_image( $attachment_id ): void {
     162        delete_metadata( 'post', 0, SHARE_IMAGE_KEY, (int) $attachment_id, true );
     163}
     164
     165/**
     166 * The post's own share image (attachment ID), or 0 to use the featured image.
     167 *
     168 * @param int $post_id Post ID.
     169 */
     170function get_share_image_id( int $post_id ): int {
     171        return absint( get_post_meta( $post_id, SHARE_IMAGE_KEY, true ) );
    107172}
    108173
  • simple-seo/trunk/src/settings.php

    r3720725 r3722856  
    7474        $image = share_image();
    7575
    76         printf(
    77                 '<div class="simple-seo-share-image"><img src="%1$s" alt="" style="max-width:300px;height:auto;display:block;margin-bottom:8px" %2$s />',
    78                 esc_url( $image['url'] ?? '' ),
    79                 $image ? '' : 'hidden'
    80         );
    81 
    82         printf(
    83                 '<input type="hidden" name="%1$s" value="%2$s" />',
    84                 esc_attr( SHARE_IMAGE_OPTION ),
    85                 esc_attr( (string) ( $image['id'] ?? '' ) )
    86         );
    87 
    88         printf(
    89                 '<button type="button" class="button simple-seo-share-image-choose" data-choose="%1$s" data-change="%2$s">%3$s</button> ',
    90                 esc_attr__( 'Choose image', 'simple-seo' ),
    91                 esc_attr__( 'Change image', 'simple-seo' ),
    92                 $image ? esc_html__( 'Change image', 'simple-seo' ) : esc_html__( 'Choose image', 'simple-seo' )
    93         );
    94 
    95         printf(
    96                 '<button type="button" class="button button-secondary simple-seo-share-image-remove" %1$s>%2$s</button></div>',
    97                 $image ? '' : 'hidden',
    98                 esc_html__( 'Remove image', 'simple-seo' )
    99         );
     76        image_field( SHARE_IMAGE_OPTION, (int) ( $image['id'] ?? 0 ), (string) ( $image['url'] ?? '' ) );
    10077
    10178        printf(
    … …  
    11592        }
    11693
    117         wp_enqueue_media();
    118 
    119         $asset = require dirname( __DIR__ ) . '/build/share-image-field.asset.php';
    120 
    121         wp_enqueue_script(
    122                 'simple-seo-share-image-field',
    123                 plugins_url( 'build/share-image-field.js', \SIMPLE_SEO_PLUGIN_FILE ),
    124                 $asset['dependencies'],
    125                 $asset['version'],
    126                 true
    127         );
     94        enqueue_image_field_script();
    12895}
    12996
  • simple-seo/trunk/src/simple-history.php

    r3714576 r3722856  
    5757                'noindex'          => is_noindex( $post_id ) ? __( 'Yes', 'simple-seo' ) : __( 'No', 'simple-seo' ),
    5858                'menu_label'       => get_menu_label( $post_id ),
     59                'share_image'      => (string) wp_get_attachment_url( get_share_image_id( $post_id ) ),
    5960        ];
    6061}
Note: See TracChangeset for help on using the changeset viewer.