array( $this, 'render_ask' ) ) ); register_block_type( PLUGIN_DIR . 'blocks/search', array( 'render_callback' => array( $this, 'render_search' ) ) ); } /** * The Ask panel. * * @param array $attributes Block attributes. * * @return string */ public function render_ask( array $attributes = array() ): string { // Set in the editor's "Ask this straight away" field: an author asking // for an answer the moment the page opens. $authored = trim( (string) ( $attributes['question'] ?? '' ) ); $question = $authored; if ( '' === $question ) { // Whatever the visitor just typed — into the theme's search box or // into the Search block beside this one — goes into the box, so // they do not have to type it twice. It is **not** asked: pressing // Search is a request for search results, and putting the same // words to the agent as well was both a surprise and an agent // call per visitor per search. $question = self::query(); } return ( new Ask( $this->connection ) )->panel( $question, (string) ( $attributes['placeholder'] ?? '' ), self::wrapper( 'sai-ask' ), '' !== $authored ); } /** * A search box and its results, for a page of the editor's own making. * * The theme's own search page is handled without any block at all — this * is for a site that wants a search somewhere else, or wants one without * making it the site's search. * * @param array $attributes Block attributes. * * @return string */ public function render_search( array $attributes = array() ): string { $per_page = max( 1, min( 50, (int) ( $attributes['perPage'] ?? 10 ) ) ); $show_form = (bool) ( $attributes['showForm'] ?? true ); $placeholder = (string) ( $attributes['placeholder'] ?? '' ); $keys = self::query(); // A block with its box turned off and nothing searched for has nothing // to say. Returning the empty wrapper instead left a styled, padded box // sitting on the page with nothing in it. if ( ! $show_form && '' === $keys ) { return ''; } if ( wp_style_is( self::THEME_STYLE, 'registered' ) ) { wp_enqueue_style( self::THEME_STYLE ); } $out = '
'; if ( $show_form ) { $out .= $this->form( $keys, $placeholder ); } if ( '' !== $keys ) { $out .= $this->results( $keys, $per_page ); } $out .= '
'; return $out; } /** * The outer element's attributes: the editor's choices, or just our class. * * `get_block_wrapper_attributes()` reads the block currently being * rendered out of `WP_Block_Supports`, and there is not always one — these * render methods are public, the Ask panel is also a shortcode, and a * theme or another plugin may call either directly. Core does not guard * that case: it indexes the null and takes the page down with a fatal. * Outside a block render there are no supports to apply anyway, so the * honest answer is our own class and nothing else. * * @param string $classname The plugin's own hook for site CSS. */ private static function wrapper( string $classname ): string { if ( null === ( \WP_Block_Supports::$block_to_render['blockName'] ?? null ) ) { return 'class="' . esc_attr( $classname ) . '"'; } return get_block_wrapper_attributes( array( 'class' => $classname ) ); } /** * What this request is searching for, as far as the block is concerned. * * Its own parameter first; `s` after it, so the block still works when * somebody puts it on the theme's search template. */ public static function query(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a search term from a GET form; reading one is not a change. $own = isset( $_GET[ self::QUERY ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QUERY ] ) ) : ''; return '' !== trim( $own ) ? trim( $own ) : trim( (string) get_search_query() ); } /** * The box. Submits to this same page, so results land where the block is. * * Marked up as core's Search block, down to the `__inside-wrapper` that * holds the input and the button in a row: that is where core's own layout * lives, and copying the structure is what makes a theme's search styling * apply here without this plugin shipping a single colour. The `sai-` * classes stay alongside as hooks for a site that wants to target ours in * particular. * * The id is unique per panel. Two of these on one page — a block in the * content and another in a template part — shared one id, which left the * second box's label pointing at the first box. * * @param string $keys What was searched for. * @param string $placeholder Placeholder text. */ private function form( string $keys, string $placeholder ): string { $placeholder = '' !== $placeholder ? $placeholder : __( 'Search this site…', 'sharelyai' ); $id = wp_unique_id( 'sai-search-input-' ); list( $action, $carried ) = self::target(); return sprintf( '', esc_url( $action ), esc_html__( 'Search', 'sharelyai' ), esc_attr( $keys ), esc_attr( $placeholder ), esc_html__( 'Search', 'sharelyai' ), esc_attr( $id ), $carried ); } /** * Where a form on this page posts, and the fields it has to carry. * * A block placed on a page is a search *for that page*. Sending its form * to `/?s=` would take the visitor somewhere else entirely, which is the * one thing somebody who built their own search page did not want. * * The second half of that is the part this got wrong, and it is worth * spelling out because it looks like it should work. **A GET form does not * add to its action's query string — it replaces it.** On a site with * plain permalinks a page is `/?page_id=21`, so a form with * `action="/?page_id=21"` and one field called `sai_q` submits to * `/?sai_q=…`: `page_id` is gone, and the visitor lands on the home page * with the block they were using nowhere in sight. Which is exactly what * it did. * * So the action carries no query, and anything that was in it comes back * as a hidden field. Pretty permalinks have nothing to carry and get * nothing; the two behave the same either way, which is the point. * * Static because the Ask panel needs the same two values and is not a * block — it is also a shortcode, and in both shapes a form that threw the * page away would throw it away identically. * * @return array{0: string, 1: string} The action, and the hidden fields. */ public static function target(): array { $page = get_queried_object(); $url = home_url( '/' ); if ( $page instanceof \WP_Post ) { $link = get_permalink( $page ); if ( is_string( $link ) && '' !== $link ) { $url = $link; } } $split = explode( '?', $url, 2 ); $action = $split[0]; $query = $split[1] ?? ''; if ( '' === $query ) { return array( $action, '' ); } $args = array(); wp_parse_str( $query, $args ); // Our own box provides this one; a hidden copy would be submitted // alongside the visitor's and the two would fight. unset( $args[ self::QUERY ] ); $hidden = ''; foreach ( $args as $key => $value ) { if ( ! is_scalar( $value ) ) { // A nested array needs bracketed names to survive the round // trip, and no permalink WordPress builds has one. Dropping it // is better than writing a name that means something else. continue; } $hidden .= sprintf( '', esc_attr( (string) $key ), esc_attr( (string) $value ) ); } return array( $action, $hidden ); } /** * The results, from Sharely when it can and from WordPress when it cannot. * * @param string $keys What was searched for. * @param int $per_page How many to show. */ private function results( string $keys, int $per_page ): string { $found = ( new Search( $this->connection ) )->results( $keys ); $posts = array(); if ( $found['ok'] && array() !== $found['ids'] ) { $posts = get_posts( array( 'post__in' => array_slice( $found['ids'], 0, $per_page ), 'orderby' => 'post__in', 'posts_per_page' => $per_page, 'post_type' => $this->connection->post_types(), // Vetted by Search::results(); a private post here is one // this visitor is entitled to read. 'post_status' => array( 'publish', 'private' ), 'has_password' => false, 'ignore_sticky_posts' => true, 'no_found_rows' => true, ) ); } elseif ( ! $found['ok'] || ! $found['empty'] ) { // Sharely could not answer, or answered with things this site // cannot place. Either way WordPress can still search its own // posts, and an empty page would be a worse answer than its. $posts = get_posts( array( 's' => $keys, 'posts_per_page' => $per_page, 'post_type' => $this->connection->post_types(), 'post_status' => 'publish', 'has_password' => false, 'ignore_sticky_posts' => true, 'no_found_rows' => true, ) ); } if ( array() === $posts ) { return '

' . esc_html__( 'Nothing matched that search.', 'sharelyai' ) . '

'; } $out = '
    '; foreach ( $posts as $post ) { $out .= sprintf( '
  1. %2$s%3$s
  2. ', esc_url( (string) get_permalink( $post ) ), esc_html( get_the_title( $post ) ), $this->excerpt( $post ) ); } $out .= '
'; return $out; } /** * A line of the post, from WordPress rather than from the platform. * * @param \WP_Post $post The post. */ private function excerpt( \WP_Post $post ): string { $excerpt = wp_trim_words( wp_strip_all_tags( (string) get_the_excerpt( $post ) ), 30 ); return '' === $excerpt ? '' : '

' . esc_html( $excerpt ) . '

'; } }