` that becomes something else — * reaches every post that ever used the block, instead of only the ones somebody * re-saves, and no post is ever left with an invalid block because the markup * moved on. * * The summary block renders with the `acid-aeo-summary` class on purpose: * `Generators\SchemaGraphBuilder` names that selector in the `speakable` node, * so the two have to agree. */ final class Blocks { /** * Block whose `items` attribute holds the key takeaways. * * The other two block names live on `Data\ContentData`, which reads them out * of post content; this one is only ever rendered, so it belongs here. * * @var string */ public const TAKEAWAYS_BLOCK = 'acid-aeo/key-takeaways'; /** * Handle of the editor script that registers the blocks in JavaScript. * * @var string */ public const EDITOR_SCRIPT = 'acid-aeo-blocks'; /** * Handle of the stylesheet that styles the blocks inside the editor. * * @var string */ public const EDITOR_STYLE = 'acid-aeo-blocks-editor'; /** * Handle of the stylesheet that styles the blocks on the front end. * * @var string */ public const STYLE = 'acid-aeo-blocks'; /** * Hooks the block registration into WordPress. * * The assets are registered first, at an earlier `init`, because * `register_block_type()` records handles and WordPress enqueues them later * by name — a handle that is not registered by then is silently dropped. * * @return void */ public function register(): void { add_action( 'init', [ $this, 'register_assets' ], 5 ); add_action( 'init', [ $this, 'register_blocks' ] ); } /** * Registers the editor script and the two stylesheets. * * @return void */ public function register_assets(): void { wp_register_script( self::EDITOR_SCRIPT, ACID_AEO_URL . 'assets/editor/blocks.js', [ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n' ], ACID_AEO_VERSION, true ); wp_set_script_translations( self::EDITOR_SCRIPT, 'acid-aeo' ); wp_register_style( self::EDITOR_STYLE, ACID_AEO_URL . 'assets/editor/blocks.css', [], ACID_AEO_VERSION ); wp_register_style( self::STYLE, ACID_AEO_URL . 'assets/public/blocks.css', [], ACID_AEO_VERSION ); } /** * Registers the three block types. * * @return void */ public function register_blocks(): void { register_block_type( ContentData::SUMMARY_BLOCK, [ 'api_version' => 3, 'title' => __( 'AI Summary (TL;DR)', 'acid-aeo' ), 'description' => __( 'The short answer this page leads with. Answer engines quote it and the schema marks it speakable.', 'acid-aeo' ), 'category' => 'text', 'icon' => 'excerpt-view', 'keywords' => [ __( 'summary', 'acid-aeo' ), __( 'tldr', 'acid-aeo' ), __( 'abstract', 'acid-aeo' ) ], 'attributes' => [ 'label' => [ 'type' => 'string', 'default' => __( 'In short', 'acid-aeo' ), ], 'text' => [ 'type' => 'string', 'default' => '', ], ], 'editor_script' => self::EDITOR_SCRIPT, 'editor_style' => self::EDITOR_STYLE, 'style' => self::STYLE, 'render_callback' => [ $this, 'render_summary' ], 'supports' => [ 'html' => false, 'multiple' => false, ], ] ); register_block_type( ContentData::FAQ_BLOCK, [ 'api_version' => 3, 'title' => __( 'AI FAQ', 'acid-aeo' ), 'description' => __( 'Question and answer pairs, published as an FAQPage node and listed in llms.txt.', 'acid-aeo' ), 'category' => 'text', 'icon' => 'editor-help', 'keywords' => [ __( 'faq', 'acid-aeo' ), __( 'questions', 'acid-aeo' ) ], 'attributes' => [ 'heading' => [ 'type' => 'string', 'default' => __( 'Frequently asked questions', 'acid-aeo' ), ], 'items' => [ 'type' => 'array', 'default' => [], ], ], 'editor_script' => self::EDITOR_SCRIPT, 'editor_style' => self::EDITOR_STYLE, 'style' => self::STYLE, 'render_callback' => [ $this, 'render_faq' ], 'supports' => [ 'html' => false ], ] ); register_block_type( self::TAKEAWAYS_BLOCK, [ 'api_version' => 3, 'title' => __( 'Key takeaways', 'acid-aeo' ), 'description' => __( 'A short list of what the reader should remember, easy for a model to lift verbatim.', 'acid-aeo' ), 'category' => 'text', 'icon' => 'editor-ul', 'keywords' => [ __( 'takeaways', 'acid-aeo' ), __( 'highlights', 'acid-aeo' ) ], 'attributes' => [ 'heading' => [ 'type' => 'string', 'default' => __( 'Key takeaways', 'acid-aeo' ), ], 'items' => [ 'type' => 'array', 'default' => [], ], ], 'editor_script' => self::EDITOR_SCRIPT, 'editor_style' => self::EDITOR_STYLE, 'style' => self::STYLE, 'render_callback' => [ $this, 'render_takeaways' ], 'supports' => [ 'html' => false ], ] ); } /** * Renders the TL;DR block. * * A block with no text renders nothing: an empty box says less than no box, * and it would still be picked up as the page's speakable summary. * * @param mixed $attributes Block attributes, as WordPress passes them. * @return string HTML, empty when there is nothing to say. */ public function render_summary( $attributes = [] ): string { $attributes = is_array( $attributes ) ? $attributes : []; $label = self::text( $attributes['label'] ?? __( 'In short', 'acid-aeo' ) ); $text = self::text( $attributes['text'] ?? '' ); if ( '' === $text ) { return ''; } $html = '
'; if ( '' !== $label ) { $html .= '

' . esc_html( $label ) . '

'; } return $html . '

' . esc_html( $text ) . '

'; } /** * Renders the FAQ block. * * Answers keep their markup — an answer is often a paragraph with a link — * so they go through `wpautop()` and then `wp_kses_post()`; questions are * plain text and are escaped as such. * * @param mixed $attributes Block attributes, as WordPress passes them. * @return string HTML, empty when the block holds no complete pair. */ public function render_faq( $attributes = [] ): string { $attributes = is_array( $attributes ) ? $attributes : []; $heading = self::text( $attributes['heading'] ?? '' ); $items = self::pairs( $attributes['items'] ?? [] ); if ( [] === $items ) { return ''; } $html = '
'; if ( '' !== $heading ) { $html .= '

' . esc_html( $heading ) . '

'; } foreach ( $items as $item ) { $html .= '
' . esc_html( $item['q'] ) . '' . '
' . wp_kses_post( wpautop( $item['a'] ) ) . '
'; } return $html . '
'; } /** * Renders the key takeaways block. * * @param mixed $attributes Block attributes, as WordPress passes them. * @return string HTML, empty when the list is empty. */ public function render_takeaways( $attributes = [] ): string { $attributes = is_array( $attributes ) ? $attributes : []; $heading = self::text( $attributes['heading'] ?? '' ); $items = self::lines( $attributes['items'] ?? [] ); if ( [] === $items ) { return ''; } $html = ''; } /** * One attribute as trimmed plain text. * * @param mixed $value Raw attribute. * @return string Plain text, empty when the attribute is not a scalar. */ private static function text( $value ): string { return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : ''; } /** * The complete question and answer pairs of an `items` attribute. * * @param mixed $value Raw attribute. * @return array> Rows of `q` and `a`. */ private static function pairs( $value ): array { $items = []; foreach ( is_array( $value ) ? $value : [] as $row ) { if ( ! is_array( $row ) ) { continue; } $question = self::text( $row['q'] ?? '' ); $answer = is_scalar( $row['a'] ?? null ) ? trim( (string) $row['a'] ) : ''; if ( '' === $question || '' === $answer ) { continue; } $items[] = [ 'q' => $question, 'a' => $answer, ]; } return $items; } /** * The non-empty strings of an `items` attribute. * * @param mixed $value Raw attribute. * @return array Plain text lines. */ private static function lines( $value ): array { $lines = []; foreach ( is_array( $value ) ? $value : [] as $row ) { $line = self::text( $row ); if ( '' !== $line ) { $lines[] = $line; } } return $lines; } }