settings = $settings;
}
/**
* Register the post meta, the title filter and the editor save handler.
*/
public function register() {
// The meta registers regardless of mode or toggle, so a value set in solo
// mode survives (and stays REST-visible) through a suite trial and back —
// the same data-preservation stance every other Agentimus meta takes.
add_action( 'init', array( __CLASS__, 'register_meta' ) );
if ( is_admin() ) {
add_action( 'save_post', array( $this, 'save' ), 10, 2 );
}
// Each surface rides its own toggle; every one re-checks the mode at
// request time, so these hooks are inert the moment a suite activates.
if ( $this->settings->enabled( 'enable_seo_titles' ) ) {
// After core builds the parts (10) but early enough that a theme
// tweaking its own title at a later priority still wins — Agentimus
// supplies the value, it doesn't insist on the last word.
add_filter( 'document_title_parts', array( $this, 'filter_title_parts' ), 20 );
}
if ( $this->settings->enabled( 'enable_social_cards' ) || $this->settings->enabled( 'enable_canonicals' ) ) {
// Cards + canonical are GAP-ONLY, like the description meta tag: many
// themes (the-alpha among them) and plugins emit their own og:/canonical
// tags, and a hardcoded list of them would never be complete. So the
// head is buffered and ours append only when nobody else printed one.
// Nested INSIDE Description's buffer (-99999/99999): opens stack, closes
// unwind in reverse, and its description regex never matches our tags.
add_action( 'wp_head', array( $this, 'buffer_start' ), self::HEAD_BUFFER_OPEN );
add_action( 'wp_head', array( $this, 'buffer_end' ), self::HEAD_BUFFER_CLOSE );
}
}
/**
* Open the head buffer on a candidate request — solo mode is the one gate
* both surfaces share; each printer re-runs its own finer gates at close.
* A no-op in coexist mode, so suite-run sites carry no buffering overhead.
*/
public function buffer_start() {
try {
$solo = SeoContext::solo(); // Runs the agentimus_solo_mode filter (third-party code).
} catch ( \Throwable $e ) {
return; // Degrade to "don't buffer, don't emit".
}
if ( ! $solo ) {
return;
}
$this->buffering = true;
ob_start();
}
/**
* Close the buffer, print the head unchanged, then append whichever of our
* surfaces the head still lacks. The head itself is echoed BEFORE the gap
* checks run, so no failure of ours can ever cost the page its head.
*/
public function buffer_end() {
if ( ! $this->buffering ) {
return;
}
$this->buffering = false;
$head = (string) ob_get_clean();
echo $head; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the page's own head, passed through untouched.
try {
if ( $this->settings->enabled( 'enable_social_cards' ) && ! preg_match( '/property\s*=\s*["\']og:/i', $head ) ) {
$this->output_social_cards();
}
if ( $this->settings->enabled( 'enable_canonicals' ) && ! preg_match( '/rel\s*=\s*["\']canonical["\']/i', $head ) ) {
$this->output_canonical();
}
} catch ( \Throwable $e ) {
return; // Head already printed; a failed append prints nothing.
}
}
/**
* Register the SEO-title meta on every agent-visible post type — sanitised on
* write, exposed to the REST/block editor, writes gated to users who can edit
* the post. Mirrors {@see Description::register_meta()}.
*/
public static function register_meta() {
foreach ( Content::post_types() as $type ) {
register_post_meta(
$type,
self::META_TITLE,
array(
'type' => 'string',
'single' => true,
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => array( __CLASS__, 'sanitize_title' ),
'auth_callback' => array( Description::class, 'can_edit' ),
)
);
}
}
/**
* Whether the SEO-title editor field should show: feature on AND solo mode.
* In coexist mode the suite has its own title field — showing a second one
* would put two competing inputs on the same screen.
*
* @return bool
*/
public static function title_ui_enabled() {
return (bool) ( new Settings() )->enabled( 'enable_seo_titles' ) && SeoContext::solo();
}
/**
* Swap the title part for the editor's SEO title, when there is one to swap.
*
* @param array $parts Core's document title parts.
* @return array
*/
public function filter_title_parts( $parts ) {
$title = $this->custom_title();
if ( '' !== $title && is_array( $parts ) ) {
$parts['title'] = $title;
}
return $parts;
}
/**
* The SEO title for the current request, or '' when any gate says no:
* feature off, coexist mode, not a singular covered view, or no value set.
*
* @return string
*/
private function custom_title() {
if ( ! $this->settings->enabled( 'enable_seo_titles' ) ) {
return '';
}
if ( ! SeoContext::solo() ) {
return '';
}
if ( ! is_singular( Content::post_types() ) ) {
return '';
}
$post = get_post();
if ( ! $post ) {
return '';
}
return self::sanitize_title( get_post_meta( $post->ID, self::META_TITLE, true ) );
}
/**
* Normalise a title: strip tags, collapse whitespace to single spaces, trim,
* clip to the cap (multibyte-aware). Same discipline as Description::clean() —
* the value ends up inside a
element and later a social-card attribute.
*
* @param mixed $value Raw meta/input value.
* @return string
*/
public static function sanitize_title( $value ) {
$value = wp_strip_all_tags( (string) $value );
$value = trim( (string) preg_replace( '/\s+/', ' ', $value ) );
$value = function_exists( 'mb_substr' ) ? mb_substr( $value, 0, self::TITLE_MAX_LEN ) : substr( $value, 0, self::TITLE_MAX_LEN );
return rtrim( $value );
}
/* ---------------------------------------------------------------------- *
* Open Graph / X share cards
* ---------------------------------------------------------------------- */
/**
* Print the share-card tags for the front page and singular covered views.
* Called by {@see buffer_end()} only when the head has no og: tags from any
* other source (a theme, Jetpack, a social plugin) — the gap check IS the
* stand-down, so there is no emitter list to maintain. The filter below is
* the escape hatch for a source the buffer can't see (tags printed outside
* wp_head).
*/
public function output_social_cards() {
if ( ! SeoContext::solo() ) {
return;
}
/**
* Filter whether Agentimus prints Open Graph/X card tags on this request.
*
* @param bool $emit Whether to emit.
*/
if ( ! apply_filters( 'agentimus_emit_social_cards', true ) ) {
return;
}
$tags = $this->social_card_tags();
if ( empty( $tags ) ) {
return;
}
foreach ( $tags as $key => $value ) {
// og:* live in `property`, twitter:* in `name` — that's each spec's attribute.
$attr = ( 0 === strpos( $key, 'twitter:' ) ) ? 'name' : 'property';
$is_url = in_array( $key, array( 'og:url', 'og:image' ), true );
$content = $is_url ? esc_url( $value ) : esc_attr( $value );
echo '' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $content escaped per-kind above.
}
}
/**
* The card tags for the current view, or an empty array off card-worthy views.
* Front page (either kind) is a `website`; a singular covered, published,
* non-password view is an `article`. Archives get no cards — shares point at
* posts, not at listings.
*
* @return array Ordered tag map: og:/twitter: key => content.
*/
private function social_card_tags() {
$front = is_front_page();
$post = is_singular( Content::post_types() ) ? get_post() : null;
if ( $post && ( 'publish' !== $post->post_status || '' !== (string) $post->post_password ) ) {
$post = null; // Never describe (or leak the featured image of) a non-public post.
}
if ( ! $front && ! $post ) {
return array();
}
if ( $front ) {
// A static front page enriches the website card; the blog-as-front
// case falls back to the site's own name and tagline.
$type = 'website';
$title = (string) get_bloginfo( 'name' );
$desc = $post ? Description::for_post( $post ) : '';
$desc = ( '' !== $desc ) ? $desc : (string) get_bloginfo( 'description' );
$url = home_url( '/' );
} else {
$type = 'article';
$custom = self::sanitize_title( get_post_meta( $post->ID, self::META_TITLE, true ) );
$title = ( '' !== $custom ) ? $custom : self::sanitize_title( get_the_title( $post ) );
$desc = Description::for_post( $post );
$url = (string) get_permalink( $post );
}
$tags = array(
'og:type' => $type,
'og:site_name' => (string) get_bloginfo( 'name' ),
'og:locale' => (string) get_locale(),
'og:title' => $title,
'og:url' => $url,
);
if ( '' !== $desc ) {
$tags['og:description'] = $desc;
}
$image = $this->social_image( $post );
if ( null !== $image ) {
$tags['og:image'] = $image['url'];
if ( $image['width'] > 0 && $image['height'] > 0 ) {
$tags['og:image:width'] = (string) $image['width'];
$tags['og:image:height'] = (string) $image['height'];
}
if ( '' !== $image['alt'] ) {
$tags['og:image:alt'] = $image['alt'];
}
}
// The large-image card only when there is an image to be large with.
$tags['twitter:card'] = ( null !== $image ) ? 'summary_large_image' : 'summary';
/**
* Filter the share-card tag map before printing (og:/twitter: key => content).
* Values are escaped after this filter, so a provider can't break the head.
*
* @param array $tags The tag map.
* @param \WP_Post|null $post The post being carded, null on a blog-as-front view.
*/
return (array) apply_filters( 'agentimus_social_card_tags', $tags, $post );
}
/**
* The card image, first match wins: the post's featured image → the owner's
* site-wide default (`social_default_image`) → the entity image (Site Icon or
* the `agentimus_entity_image` override) that Schema already publishes.
*
* Public because it is the single answer to "what image will this page share?" —
* the editor panel's Share tab shows it as the link preview.
*
* @param \WP_Post|null $post The post, when the view has one.
* @return array{url:string,width:int,height:int,alt:string}|null
*/
public function social_image( $post ) {
if ( $post && function_exists( 'get_post_thumbnail_id' ) ) {
$image = self::attachment_image( (int) get_post_thumbnail_id( $post ) );
if ( null !== $image ) {
return $image;
}
}
$image = self::attachment_image( (int) $this->settings->get( 'social_default_image', 0 ) );
if ( null !== $image ) {
return $image;
}
$url = ( new Schema( $this->settings ) )->entity_image();
if ( '' !== $url ) {
return array(
'url' => $url,
'width' => 0,
'height' => 0,
'alt' => '',
);
}
return null;
}
/**
* Resolve an attachment into card-image facts, or null when it isn't one.
*
* @param int $id Attachment ID (0 = none).
* @return array{url:string,width:int,height:int,alt:string}|null
*/
private static function attachment_image( $id ) {
if ( $id < 1 || ! function_exists( 'wp_get_attachment_image_src' ) ) {
return null;
}
$src = wp_get_attachment_image_src( $id, 'full' );
if ( ! is_array( $src ) || empty( $src[0] ) ) {
return null;
}
return array(
'url' => (string) $src[0],
'width' => isset( $src[1] ) ? (int) $src[1] : 0,
'height' => isset( $src[2] ) ? (int) $src[2] : 0,
'alt' => (string) get_post_meta( $id, '_wp_attachment_image_alt', true ),
);
}
/* ---------------------------------------------------------------------- *
* Canonical links
* ---------------------------------------------------------------------- */
/**
* Print a canonical link on the views core leaves bare. Core's rel_canonical
* covers EVERY singular view (a static front page included), so this only
* fills in the blog-as-front page and the main archive kinds. Called by
* {@see buffer_end()} only when the head has no canonical from any other
* source (core's, a theme's).
*/
public function output_canonical() {
if ( ! SeoContext::solo() ) {
return;
}
$url = $this->canonical_url();
/**
* Filter the canonical URL Agentimus is about to print ('' = print nothing).
*
* @param string $url The resolved canonical URL.
*/
$url = (string) apply_filters( 'agentimus_canonical_url', $url );
if ( '' === $url ) {
return;
}
echo '' . "\n";
}
/**
* The canonical URL for the current view, or '' when this is not our view to
* canonicalise: singular (core's job), paginated pages 2+ (a canonical must be
* self-referential there, and building paged permalinks across permalink
* structures is a swamp — emitting NOTHING is honest, pointing page 5 at page 1
* would be wrong), search, 404s, and date archives (out of scope, low value).
*
* @return string
*/
private function canonical_url() {
if ( is_singular() ) {
return '';
}
if ( function_exists( 'is_paged' ) && is_paged() ) {
return '';
}
if ( is_front_page() ) {
return home_url( '/' );
}
if ( is_category() || is_tag() || is_tax() ) {
$link = get_term_link( get_queried_object() );
return is_string( $link ) ? $link : ''; // WP_Error → nothing, never a guessed URL.
}
if ( is_post_type_archive() ) {
$type = get_query_var( 'post_type' );
$type = is_array( $type ) ? (string) reset( $type ) : (string) $type;
$link = get_post_type_archive_link( $type );
return is_string( $link ) ? $link : '';
}
if ( is_author() ) {
return (string) get_author_posts_url( (int) get_queried_object_id() );
}
return '';
}
/**
* Render the SEO-title field — called by {@see Topics::render_meta_box()} as
* the top section of the shared editor box, above the AI description, the
* order a search snippet shows them.
*
* @param \WP_Post $post Post being edited.
*/
public function render_title_field( $post ) {
wp_nonce_field( self::NONCE, self::NONCE );
$value = self::sanitize_title( get_post_meta( $post->ID, self::META_TITLE, true ) );
$obj = get_post_type_object( $post->post_type );
$noun = ( $obj && ! empty( $obj->labels->singular_name ) ) ? strtolower( $obj->labels->singular_name ) : $post->post_type;
echo '
';
// The field's NAME first (scannable), its explanation second — three
// fields share this box, so each needs a heading of its own.
echo '';
echo '
'
. esc_html(
sprintf(
/* translators: %s: the content type in lowercase, e.g. "post", "page", "product". */
__( 'Title for search results and browser tabs. Leave blank to use this %s’s own title. Your site name stays appended either way.', 'agentimus' ),
$noun
)
)
. '
';
echo '';
echo '
';
}
/**
* Classic-editor save for the SEO title. Nonce + autosave/revision + capability
* guards mirror {@see Description::save()}; only acts when this POST carried our
* field, so quick-edit and REST saves never wipe it.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post — unused (kept for the save_post signature).
*/
public function save( $post_id, $post ) {
if ( ! MetaSave::verified( $post_id, self::NONCE ) ) {
return;
}
$raw = isset( $_POST['agentimus_seo_title'] ) ? wp_unslash( $_POST['agentimus_seo_title'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_title() below.
$value = self::sanitize_title( $raw );
if ( '' === $value ) {
delete_post_meta( $post_id, self::META_TITLE );
} else {
update_post_meta( $post_id, self::META_TITLE, $value );
}
}
}