*/ private const FIELDS = [ 'summary' => 'acid_aeo_summary', 'schema_type' => 'acid_aeo_schema_type', 'omit' => 'acid_aeo_exclude', 'noai' => 'acid_aeo_noai', 'faq' => 'acid_aeo_faq', 'hide_updated' => 'acid_aeo_hide_updated', ]; /** * Registers the post metas. * * Runs after the post types are registered, so `post_types()` can ask each * of them whether it is public. * * @return void */ public function register(): void { add_action( 'init', [ $this, 'register_meta' ], 20 ); } /** * Adds the meta box to the editor. * * @return void */ public function register_box(): void { add_action( 'add_meta_boxes', [ $this, 'add' ] ); } /** * The post types the fields apply to. * * The union of the two lists the plugin already asks the site owner for: * what goes into `llms.txt` and what is pinged to IndexNow. A post type that * is not public is dropped — nothing this plugin publishes can reach it. * * @return array Post type names, without duplicates. */ public function post_types(): array { $llms = Options::get( 'llms.post_types', [] ); $indexnow = Options::get( 'indexnow.post_types', [] ); $names = array_merge( is_array( $llms ) ? $llms : [], is_array( $indexnow ) ? $indexnow : [] ); $types = []; foreach ( $names as $name ) { $name = sanitize_key( (string) $name ); if ( '' === $name || isset( $types[ $name ] ) ) { continue; } $object = get_post_type_object( $name ); if ( null === $object || empty( $object->public ) ) { continue; } $types[ $name ] = $name; } return array_values( $types ); } /** * Registers the six metas on every post type the fields apply to. * * @return void */ public function register_meta(): void { foreach ( $this->post_types() as $post_type ) { foreach ( self::meta_specs() as $key => $spec ) { register_post_meta( $post_type, $key, $spec ); } } } /** * Adds the box to the editor screen of every listed post type. * * @return void */ public function add(): void { $post_types = $this->post_types(); if ( [] === $post_types ) { return; } add_meta_box( self::BOX_ID, __( 'AI readiness', 'acid-aeo' ), [ $this, 'render' ], $post_types, 'side', 'default' ); } /** * Prints the fields. * * @param mixed $post Post being edited, as `add_meta_box` passes it. * @return void */ public function render( $post = null ): void { if ( ! $post instanceof WP_Post ) { return; } $post_id = (int) $post->ID; wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); $this->render_summary( $post_id ); $this->render_schema_type( $post_id ); $this->render_checkbox( self::FIELDS['omit'], ContentData::EXCLUDE_META, $post_id, __( 'Exclude from llms.txt', 'acid-aeo' ) ); $this->render_checkbox( self::FIELDS['noai'], HeadTags::NOAI_META, $post_id, __( 'Ask AI crawlers not to train on this (noai meta)', 'acid-aeo' ) ); // Only where there is a line to hide. A checkbox that does nothing on a // site that never switched the line on is a question with no answer. if ( self::shows_updated_line() ) { $this->render_checkbox( self::FIELDS['hide_updated'], ContentTools::HIDE_UPDATED_META, $post_id, __( 'Hide the “last updated” line on this post', 'acid-aeo' ) ); } if ( ! $this->uses_block_editor( $post ) ) { $this->render_faq( $post_id ); } } /** * Saves the fields, after the nonce and the capability. * * The caller has already dropped autosaves and revisions. A request that * carries no nonce of ours never rendered the box — the block editor saves * through the REST API and the registered metas — so nothing is written and, * importantly, nothing is cleared either. * * @param int $post_id Post ID. * @return void */ public function save( int $post_id ): void { $nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : ''; if ( '' === $nonce || ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } $this->write( $post_id, ContentData::SUMMARY_META, self::sanitize_summary( $this->posted( self::FIELDS['summary'] ) ) ); $this->write( $post_id, PageData::TYPE_META, self::sanitize_schema_type( $this->posted( self::FIELDS['schema_type'] ) ) ); $this->write( $post_id, ContentData::EXCLUDE_META, self::sanitize_flag( $this->posted( self::FIELDS['omit'] ) ) ); $this->write( $post_id, HeadTags::NOAI_META, self::sanitize_flag( $this->posted( self::FIELDS['noai'] ) ) ); // An unchecked checkbox posts nothing, so a box that was never rendered // and a box that was cleared look alike. The setting decided the render, // so it decides the save too, and switching the line off site wide // cannot quietly wipe which posts had opted out of it. if ( self::shows_updated_line() ) { $this->write( $post_id, ContentTools::HIDE_UPDATED_META, self::sanitize_flag( $this->posted( self::FIELDS['hide_updated'] ) ) ); } if ( isset( $_POST[ self::FIELDS['faq'] ] ) ) { $rows = self::parse_faq_lines( $this->posted( self::FIELDS['faq'] ) ); $this->write( $post_id, ContentData::FAQ_META, [] === $rows ? '' : (string) wp_json_encode( $rows ) ); } // The summaries and FAQs memoized earlier in this request were read // before these writes; a settings screen or a `save_post` listener that // reads them back would otherwise get the values from before the save. ContentData::reset(); } /** * Turns the classic editor's FAQ textarea into rows. * * One pair per line, question and answer separated by a pipe. A line with no * pipe, or with an empty half, is dropped: an unanswered question is of no * use in `llms.txt` or in an `FAQPage` node, and silently keeping half of one * would publish it. * * Public because it is pure, and unit tested as such. * * @param string $text Raw textarea value. * @return array> Rows of `q` and `a`. */ public static function parse_faq_lines( string $text ): array { $rows = []; $lines = preg_split( '/\r\n|\r|\n/', $text ); foreach ( is_array( $lines ) ? $lines : [] as $line ) { $parts = explode( '|', (string) $line, 2 ); if ( count( $parts ) < 2 ) { continue; } $question = sanitize_text_field( $parts[0] ); $answer = sanitize_text_field( $parts[1] ); if ( '' === $question || '' === $answer ) { continue; } $rows[] = [ 'q' => $question, 'a' => $answer, ]; } return $rows; } /** * Turns stored FAQ rows back into the textarea's one-pair-per-line format. * * Public because it is pure, and unit tested as such. * * @param string $json Stored JSON, as `parse_faq_lines()` wrote it. * @return string One `Question | Answer` per line. */ public static function faq_lines( string $json ): string { $rows = json_decode( $json, true ); $lines = []; foreach ( is_array( $rows ) ? $rows : [] as $row ) { if ( ! is_array( $row ) ) { continue; } $question = is_scalar( $row['q'] ?? null ) ? trim( (string) $row['q'] ) : ''; $answer = is_scalar( $row['a'] ?? null ) ? trim( (string) $row['a'] ) : ''; if ( '' === $question || '' === $answer ) { continue; } $lines[] = $question . ' | ' . $answer; } return implode( "\n", $lines ); } /** * Whether the current user may edit posts at all. * * The `auth_callback` of every meta registered here: the per-post check * belongs to `save()` and to core's own REST permission check, which already * asks for `edit_post` on the post being written. * * @return bool True when the user can edit posts. */ public static function can_edit(): bool { return current_user_can( 'edit_posts' ); } /** * A summary as it is stored. * * @param mixed $value Raw value. * @return string Plain multi-line text. */ public static function sanitize_summary( $value ): string { return is_scalar( $value ) ? sanitize_textarea_field( (string) $value ) : ''; } /** * An article type override as it is stored. * * Anything that is neither one of the article types nor the literal `none` * becomes the empty string, which `Data\PageData` reads as "use the site's * post type map". * * @param mixed $value Raw value. * @return string One of `Data\PageData::ARTICLE_TYPES`, `none`, or an empty string. */ public static function sanitize_schema_type( $value ): string { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( 'none' === $value || in_array( $value, PageData::ARTICLE_TYPES, true ) ) { return $value; } return ''; } /** * A checkbox as it is stored. * * @param mixed $value Raw value. * @return string `1` when set, an empty string otherwise. */ public static function sanitize_flag( $value ): string { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; return '' !== $value && '0' !== $value ? '1' : ''; } /** * A FAQ meta as it is stored: normalized JSON, or nothing. * * The REST API writes this meta directly, so the same normalization the * classic editor gets is applied there too — a caller cannot store a shape * `Data\ContentData::faq_items()` would silently drop. * * @param mixed $value Raw value. * @return string JSON array of `q`/`a` rows, empty when there is none. */ public static function sanitize_faq( $value ): string { $rows = is_scalar( $value ) ? json_decode( (string) $value, true ) : null; $rows = ContentData::faq_items( is_array( $rows ) ? $rows : [] ); return [] === $rows ? '' : (string) wp_json_encode( $rows ); } /** * Whether the site prints a "last updated" line at all. * * @return bool True when the setting is on. */ private static function shows_updated_line(): bool { return (bool) Options::get( 'content.last_updated', false ); } /** * The registration arguments of every meta this box writes. * * Booleans are stored as `1` and `''` rather than as a REST boolean: the * classic editor posts a checkbox as a string, `Modules\HeadTags` reads the * flag as a string, and one representation in the database beats two. * * @return array> Meta key => `register_post_meta` arguments. */ private static function meta_specs(): array { $flag = [ 'single' => true, 'type' => 'string', 'show_in_rest' => true, 'sanitize_callback' => [ self::class, 'sanitize_flag' ], 'auth_callback' => [ self::class, 'can_edit' ], ]; return [ ContentData::SUMMARY_META => [ 'single' => true, 'type' => 'string', 'show_in_rest' => true, 'description' => __( 'The short answer this page leads with, used when it has no TL;DR block.', 'acid-aeo' ), 'sanitize_callback' => [ self::class, 'sanitize_summary' ], 'auth_callback' => [ self::class, 'can_edit' ], ], ContentData::FAQ_META => [ 'single' => true, 'type' => 'string', 'show_in_rest' => true, 'description' => __( 'Question and answer pairs as JSON, for editors without the FAQ block.', 'acid-aeo' ), 'sanitize_callback' => [ self::class, 'sanitize_faq' ], 'auth_callback' => [ self::class, 'can_edit' ], ], PageData::TYPE_META => [ 'single' => true, 'type' => 'string', 'show_in_rest' => true, 'description' => __( 'Article type for this post, overriding the site wide map.', 'acid-aeo' ), 'sanitize_callback' => [ self::class, 'sanitize_schema_type' ], 'auth_callback' => [ self::class, 'can_edit' ], ], ContentData::EXCLUDE_META => array_merge( $flag, [ 'description' => __( 'Keeps this post out of llms.txt and the other generated files.', 'acid-aeo' ) ] ), HeadTags::NOAI_META => array_merge( $flag, [ 'description' => __( 'Asks AI crawlers not to train on this post.', 'acid-aeo' ) ] ), ContentTools::HIDE_UPDATED_META => array_merge( $flag, [ 'description' => __( 'Keeps the “last updated” line off this post.', 'acid-aeo' ) ] ), ]; } /** * Prints the summary textarea. * * @param int $post_id Post ID. * @return void */ private function render_summary( int $post_id ): void { $name = self::FIELDS['summary']; printf( '

' . '' . '%4$s

', esc_attr( $name ), esc_html__( 'AI summary', 'acid-aeo' ), esc_textarea( self::meta( $post_id, ContentData::SUMMARY_META ) ), esc_html__( 'Used when no TL;DR block exists.', 'acid-aeo' ) ); } /** * Prints the article type select. * * @param int $post_id Post ID. * @return void */ private function render_schema_type( int $post_id ): void { $name = self::FIELDS['schema_type']; $current = self::sanitize_schema_type( self::meta( $post_id, PageData::TYPE_META ) ); $choices = [ '' => __( 'Default for this post type', 'acid-aeo' ) ]; foreach ( PageData::ARTICLE_TYPES as $type ) { $choices[ $type ] = $type; } $choices['none'] = __( 'No article type', 'acid-aeo' ); printf( '

' . '

'; } /** * Prints one checkbox. * * @param string $name Form field name. * @param string $key Meta key the checkbox writes. * @param int $post_id Post ID. * @param string $label Translated label. * @return void */ private function render_checkbox( string $name, string $key, int $post_id, string $label ): void { printf( '

', esc_attr( $name ), checked( '1', self::sanitize_flag( self::meta( $post_id, $key ) ), false ), esc_html( $label ) ); } /** * Prints the classic editor's FAQ repeater. * * A textarea rather than a row of JavaScript widgets: the classic editor is * the fallback path, and one pair per line is a format an author can read, * paste and diff. * * @param int $post_id Post ID. * @return void */ private function render_faq( int $post_id ): void { $name = self::FIELDS['faq']; printf( '

' . '' . '%4$s

', esc_attr( $name ), esc_html__( 'FAQ', 'acid-aeo' ), esc_textarea( self::faq_lines( self::meta( $post_id, ContentData::FAQ_META ) ) ), esc_html__( 'One pair per line, written as: Question | Answer', 'acid-aeo' ) ); } /** * Whether this post is edited in the block editor. * * @param WP_Post $post Post being edited. * @return bool True when the block editor handles it. */ private function uses_block_editor( WP_Post $post ): bool { return function_exists( 'use_block_editor_for_post' ) && use_block_editor_for_post( $post ); } /** * One posted field, unslashed and sanitized as plain text. * * Line breaks survive, because the FAQ arrives one pair per line; every * caller narrows the value further for its own field. * * The nonce is verified in `save()` before anything calls this. * * @param string $name Form field name. * @return string Sanitized value, empty when the field was not posted. */ private function posted( string $name ): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in save(), which is the only caller. if ( ! isset( $_POST[ $name ] ) || ! is_scalar( $_POST[ $name ] ) ) { return ''; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in save(), which is the only caller. return sanitize_textarea_field( (string) wp_unslash( $_POST[ $name ] ) ); } /** * Writes one meta, removing it when the value is empty. * * A row that says "no" is a row that has to be read, migrated and kept in * sync; an absent row says the same thing for free. * * @param int $post_id Post ID. * @param string $key Meta key. * @param string $value Sanitized value. * @return void */ private function write( int $post_id, string $key, string $value ): void { if ( '' === $value ) { delete_post_meta( $post_id, $key ); return; } update_post_meta( $post_id, $key, $value ); } /** * One meta value as a trimmed string. * * @param int $post_id Post ID. * @param string $key Meta key. * @return string Meta value, empty when unset or not scalar. */ private static function meta( int $post_id, string $key ): string { $value = get_post_meta( $post_id, $key, true ); return is_scalar( $value ) ? trim( (string) $value ) : ''; } }