register(); } $meta_box = new MetaBox(); $this->meta_box = $meta_box; // The metas are registered whatever the box setting says: the block // editor and the REST API read them through `show_in_rest`, and the // panel below asks the editor store for them. $meta_box->register(); if ( (bool) Options::get( 'content.meta_box', true ) ) { $meta_box->register_box(); add_action( 'save_post', [ $this, 'save_meta' ], 10, 2 ); } if ( (bool) Options::get( 'content.last_updated', false ) ) { add_filter( 'the_content', [ $this, 'last_updated' ], 12 ); } ( new MarkdownEndpoint() )->register(); if ( (bool) Options::get( 'content.editor_panel', true ) ) { add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_panel' ] ); } add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); } /** * Saves the meta box, once the post is worth saving it for. * * The autosave, revision and post type checks are the ones every module * shares; the nonce and the capability belong to the box and are checked * there. * * @param mixed $post_id Post ID, as `save_post` passes it. * @param mixed $post Post object, as `save_post` passes it. * @return void */ public function save_meta( $post_id, $post = null ): void { if ( null !== $this->meta_box && $this->is_listed_post( (int) $post_id, $post ) ) { $this->meta_box->save( (int) $post_id ); } } /** * Prepends the date a post was last revised. * * Only when the revision is a real one: a post edited the day it was * published has not been "updated", and saying so on every page teaches a * reader to ignore the line. A day is the threshold, so a typo fixed an hour * after publishing stays invisible. * * Three things can still silence it: the `content.last_updated` setting, * which never registered this filter in the first place; the per post meta, * for the one page the line does not suit; and the `acid_aeo_last_updated_html` * filter, for a theme that would rather print it itself. * * @param mixed $content Post content, as `the_content` passes it. * @return string Content, with the line in front of it when it is warranted. */ public function last_updated( $content ): string { $content = is_string( $content ) ? $content : ''; if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } $post = get_post(); if ( ! $post instanceof WP_Post ) { return $content; } if ( '1' === (string) get_post_meta( (int) $post->ID, self::HIDE_UPDATED_META, true ) ) { return $content; } /** * Filters how far apart publishing and revising have to be. * * A site that corrects posts in bursts may want a week; a changelog may * want every save to count, which zero gives it. * * @since 1.0.1 * * @param int $threshold Seconds between publishing and revising. Default one day. * @param WP_Post $post Post being rendered. */ $threshold = (int) apply_filters( 'acid_aeo_last_updated_threshold', DAY_IN_SECONDS, $post ); $revised = self::is_revised( (int) get_post_time( 'U', true, $post ), (int) get_post_modified_time( 'U', true, $post ), $threshold ); if ( ! $revised ) { return $content; } $html = self::updated_markup( (string) get_post_modified_time( 'c', false, $post ), (string) get_post_modified_time( (string) get_option( 'date_format' ), false, $post, true ) ); /** * Filters the "updated on" paragraph before it reaches the content. * * Returning an empty string drops the line; returning other markup * replaces it, which is how a theme moves it below the content or gives * it its own wrapper. * * @since 1.0.1 * * @param string $html The paragraph, already escaped. * @param WP_Post $post Post being rendered. */ $html = (string) apply_filters( 'acid_aeo_last_updated_html', $html, $post ); return '' === trim( $html ) ? $content : $html . $content; } /** * Whether a revision is far enough from publishing to be worth saying so. * * Public because it is pure, and unit tested as such. * * @param int $published Publish time, as a Unix timestamp. * @param int $modified Last revision time, as a Unix timestamp. * @param int $threshold How far apart the two have to be, in seconds. * @return bool True when the line is warranted. */ public static function is_revised( int $published, int $modified, int $threshold ): bool { if ( $published <= 0 || $threshold < 0 ) { return false; } return $modified - $published > $threshold; } /** * The "updated on" paragraph. * * Both halves describe the same moment in the site's own timezone: the one a * machine reads carries the offset, the one a reader sees carries the site's * date format and its language. Handing the first of them UTC and the second * local time, as an earlier version did, made the two disagree by a day for * every site east of Greenwich that revised a post near midnight. * * Public because it is pure, and unit tested as such. * * @param string $iso Machine readable revision time, offset included. * @param string $display The same moment, formatted for a reader. * @return string The paragraph, or an empty string when either half is missing. */ public static function updated_markup( string $iso, string $display ): string { if ( '' === $iso || '' === $display ) { return ''; } $stamp = sprintf( '', esc_attr( $iso ), esc_html( $display ) ); $line = sprintf( /* translators: %s: the date the post was last updated, inside a time element. */ esc_html__( 'Updated on %s', 'acid-aeo' ), $stamp ); return '

' . $line . '

'; } /** * Loads the AI readiness panel into the block editor. * * The meta keys and block names the panel looks for are handed to it rather * than repeated in JavaScript, so they keep having exactly one spelling. * * The editor stylesheet is enqueued from here as well as from the blocks: * the panel is styled by it too, and the two switches are independent, so a * site with the blocks switched off would otherwise get an unstyled panel. * * @return void */ public function enqueue_panel(): void { wp_enqueue_style( Blocks::EDITOR_STYLE, ACID_AEO_URL . 'assets/editor/blocks.css', [], ACID_AEO_VERSION ); wp_enqueue_script( self::PANEL_SCRIPT, ACID_AEO_URL . 'assets/editor/panel.js', [ 'wp-plugins', 'wp-edit-post', 'wp-editor', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n' ], ACID_AEO_VERSION, true ); wp_set_script_translations( self::PANEL_SCRIPT, 'acid-aeo' ); wp_add_inline_script( self::PANEL_SCRIPT, 'window.acidAeoPanel = ' . wp_json_encode( [ 'summaryBlock' => ContentData::SUMMARY_BLOCK, 'faqBlock' => ContentData::FAQ_BLOCK, 'summaryMeta' => ContentData::SUMMARY_META, 'faqMeta' => ContentData::FAQ_META, 'titleLimit' => self::TITLE_LIMIT, ] ) . ';', 'before' ); } /** * Audit checks contributed by this module. * * @return array Checks. */ public function checks(): array { $posts = ContentData::recent_posts( [ 'post' ], self::AUDIT_POSTS ); return [ $this->summaries_check( $posts ), $this->faq_check( $posts ), ]; } /** * How many recent posts lead with an answer. * * An assistant quoting a page quotes its opening. A post that opens with * two paragraphs of throat clearing gets quoted as two paragraphs of * throat clearing. * * @param array> $posts Recent posts. * @return Check The finding. */ private function summaries_check( array $posts ): Check { $id = 'content_summaries'; $label = __( 'Posts lead with a summary', 'acid-aeo' ); $total = count( $posts ); if ( 0 === $total ) { return Check::skip( $id, $this->slug(), $label, __( 'There are no published posts to look at yet.', 'acid-aeo' ), Check::WEIGHT_NICE ); } $with = 0; foreach ( $posts as $post ) { if ( $this->has_summary( (int) ( $post['id'] ?? 0 ) ) ) { ++$with; } } if ( $with * 2 >= $total ) { return Check::pass( $id, $this->slug(), $label, sprintf( /* translators: 1: number of posts with a summary, 2: number of recent posts. */ __( '%1$d of the last %2$d posts open with a summary an assistant can quote.', 'acid-aeo' ), $with, $total ), Check::WEIGHT_NICE ); } return Check::warn( $id, $this->slug(), $label, sprintf( /* translators: 1: number of posts with a summary, 2: number of recent posts. */ __( 'Only %1$d of the last %2$d posts open with a summary. Add the TL;DR block, or fill in the summary field, so an assistant quotes the answer rather than the introduction.', 'acid-aeo' ), $with, $total ), Check::WEIGHT_NICE ); } /** * Whether the site answers questions in a form a machine can lift out. * * @param array> $posts Recent posts. * @return Check The finding. */ private function faq_check( array $posts ): Check { $id = 'content_faq'; $label = __( 'The site publishes a FAQ', 'acid-aeo' ); if ( [] !== ContentData::global_faq() ) { return Check::pass( $id, $this->slug(), $label, __( 'The site wide FAQ is published, so its answers can be quoted directly.', 'acid-aeo' ), Check::WEIGHT_NICE ); } $ids = [ ContentData::latest_post_id( [ 'post', 'page' ] ) ]; foreach ( array_merge( ContentData::key_pages(), $posts ) as $row ) { $ids[] = (int) ( $row['id'] ?? 0 ); } foreach ( array_unique( array_filter( $ids ) ) as $post_id ) { if ( [] !== ContentData::faq_for( (int) $post_id ) ) { return Check::pass( $id, $this->slug(), $label, __( 'At least one page answers questions in a FAQ block, which becomes FAQ schema.', 'acid-aeo' ), Check::WEIGHT_NICE ); } } return Check::warn( $id, $this->slug(), $label, __( 'No FAQ was found anywhere on the site. A handful of real questions and answers is the single cheapest thing to add for answer engines.', 'acid-aeo' ), Check::WEIGHT_NICE ); } /** * Whether one post carries a summary of its own. * * Only the block and the meta field count: the fallback summary derived * from the excerpt is the plugin's guess, not the author's answer. * * @param int $post_id Post ID. * @return bool True when the author wrote a summary. */ private function has_summary( int $post_id ): bool { if ( $post_id <= 0 ) { return false; } if ( '' !== trim( (string) get_post_meta( $post_id, ContentData::SUMMARY_META, true ) ) ) { return true; } return has_block( ContentData::SUMMARY_BLOCK, $post_id ); } }