Projektipäevik #8: Otsinguleht – kontekstipõhine snippet ja bold otsingusõna ümber

Sissejuhatus

Otsing on iga sisuga lehe vajalik osa. Vaikimisi WordPress’i otsing töötab (URL-il ?s=keyword), aga vaikimisi tulemustelehel on kolm puudust: pole dedikeeritud template’i, excerpt’is võib otsingusõna puududa, ja otsingusõna pole esile tõstetud.

Selles postituses ehitame otsingulehe (templates/search.html), mis kuvab pealkirjad ja kontekstipõhise snippet’i koos bold-itud otsingusõnaga. UTF-8 turvaline (eesti tähed Ä, Õ, Ü, Ö töötavad).

Postitus on kirjutatud nii, et õpilane saab seda samm-sammult järgi teha. Tähelepanu: see ülesanne paljastab WP filtrite kihti, mis on FSE-arendaja initsiatsiooni-rituaal. Soovitan lugeda lõpuni.

Eesmärk

Frontend’i tulemus, mida tahame:

  • Hero ülaosas: “Search results for: ‘otsingusõna’”
  • Otsinguvorm uuesti otsida
  • Tulemused: igaüks H2 pealkiri + excerpt-snippet
  • Pealkirjas ja snippet’is on otsingusõna paksult (bold)
  • Snippet näitab konteksti (~80 tähemärki enne ja pärast), mitte vaikimisi esimesi 55 sõna
  • “No results” sõnum, kui midagi ei leita
  • Eesti tähed töötavad otsingus

Eeldused

  • WordPress 6.x (Block Editor)
  • FSE block-teema (templates/ kaust olemas)
  • Vähemalt mõned postitused, mille sisus on otsitav sõna
  • DDEV või muu kohalik keskkond

Samm 1: search.html template

Miks?

WP template hierarchy järgi: kui URL sisaldab ?s=keyword, WP otsib search.html template’i. Kui pole, kasutab index.html fallback’ina. index.html aga ei tea otsingu kontekstist midagi – see näitab postitusi nagu peavoolus.

Dedikeeritud search.html annab:

  • Selge “Search results” pealkirja (`wp:query-title` blokiga)
  • Võimaluse uuesti otsida (`wp:search` blokk)
  • “No results” tagavara, kui midagi ei leita (`wp:query-no-results` blokk)
  • Kontrolli tulemuste-kaartide üle

Kuidas?

Loo fail wp-content/themes/[teema]/templates/search.html:

<!-- wp:template-part {"slug":"header","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">

    <!-- Hero: Search results title -->
    <!-- wp:group {"tagName":"section","align":"full","backgroundColor":"bg-gray",...} -->
    <section>
        <!-- wp:query-title {"type":"search","textAlign":"center","level":1,"showPrefix":false} /-->
        <!-- wp:paragraph {"align":"center"} -->
        <p>Allpool postitused ja lehed, mis sinu otsingule vastavad.</p>
        <!-- /wp:paragraph -->
        <!-- wp:search {"placeholder":"Otsi uuesti…","buttonText":"Otsi"} /-->
    </section>
    <!-- /wp:group -->

    <!-- Query Loop with inherit:true loeb URL'ist ?s=keyword -->
    <!-- wp:query {"query":{"perPage":10,"inherit":true,...},"align":"wide"} -->
    <div class="wp-block-query alignwide">
        <!-- wp:post-template -->
            <!-- wp:group {"tagName":"article",...} -->
            <article>
                <!-- wp:post-title {"level":2,"isLink":true,"fontSize":"large"} /-->
                <!-- wp:post-excerpt {"moreText":"Vaata edasi","showMoreOnNewLine":true} /-->
            </article>
            <!-- /wp:group -->
        <!-- /wp:post-template -->

        <!-- wp:query-pagination -->...<!-- /wp:query-pagination -->

        <!-- wp:query-no-results -->
            <!-- wp:paragraph {"align":"center"} -->
            <p>Sellele otsingule midagi ei leitud. Proovi mõnd teist sõna.</p>
            <!-- /wp:paragraph -->
        <!-- /wp:query-no-results -->
    </div>
    <!-- /wp:query -->

</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

Olulisemad blokid

  • wp:query-title type="search" – loeb URL-ist ?s väärtuse ja kuvab “Search results for: ‘keyword’”
  • wp:search – otsinguvorm. Asukoht hero sees, et kasutaja saaks uuesti otsida
  • wp:query inherit:true – Query Loop pärib otsingu-päringut URL’ist, ei vaja oma postType
  • wp:query-no-results – kuvatakse, kui päring tagastab tühja

Mida kontrollida?

Salvesta fail. Ava brauser: https://[sait]/?s=test (asenda “test” mingi sõnaga, mis on sinu postitustes). Peaksid nägema struktuuri (hero, otsinguvorm, tulemused), aga otsingusõna POLE veel paksult – seda lisame järgmistes sammudes.

Samm 2: Kontekstipõhine excerpt

Miks?

WordPress’i vaikimisi excerpt on esimesed 55 sõna sisust. Aga kui otsingusõna on lõigus 200, näeb kasutaja excerpt’is hoopis teist teksti – ei saa aru, miks see tulemus kuvatakse.

Lahendus: filter, mis OTSINGU TULEMUSTE puhul leiab otsingusõna asukoha sisus ja kuvab ~80 tähemärki enne ja pärast. Annab kohese konteksti.

Kuidas?

Ava functions.php. Lisa:

/**
 * Search: kontekstipõhine excerpt.
 */
function fse_theme_search_contextual_excerpt( $excerpt, $post ) {
    if ( ! is_search() || ! $post instanceof WP_Post ) return $excerpt;

    $query = get_search_query();
    if ( empty( $query ) ) return $excerpt;

    $content = wp_strip_all_tags( $post->post_content );
    if ( empty( $content ) ) return $excerpt;

    $pos = mb_stripos( $content, $query );
    if ( $pos === false ) return $excerpt;

    $start   = max( 0, $pos - 80 );
    $length  = mb_strlen( $query ) + 160;
    $snippet = mb_substr( $content, $start, $length );

    if ( $start > 0 ) $snippet = '… ' . $snippet;
    if ( $start + $length < mb_strlen( $content ) ) $snippet .= ' …';

    return $snippet;
}
add_filter( 'get_the_excerpt', 'fse_theme_search_contextual_excerpt', 10, 2 );

Olulised detailid

  • is_search() – ainult otsingulehel (mujal vaikimisi excerpt)
  • $post instanceof WP_Post – kaitse, kui filter saab veidra objekti
  • mb_stripos, mb_substr, mb_strlen – UTF-8 turvaline (eesti tähed!)
  • Kui otsingusõna pole sisus (võib olla pealkirjas), tagastame vaikimisi excerpt’i
  • Ellipsis (… ja …) annavad lugejale teada, et see on katkend

Mida kontrollida?

Tee hard refresh otsingulehel. Vaata excerpt’i tulemustes – kas see on nüüd otsingusõna ümbrusest, mitte vaikimisi esimesed 55 sõna? Veendu, et hard refresh oli (Cmd+Shift+R Mac, Ctrl+Shift+R muu), sest WP cache’ib agressiivselt.

Samm 3: Bold pealkirjas

Miks?

Tulemuste skannitavus paraneb dramaatiliselt, kui otsingusõna on tulemustes esile tõstetud. Kasutaja näeb kohe, miks see konkreetne postitus oli kuvatud.

Kuidas?

Lisa functions.php-sse:

/**
 * Search: bold otsingusõna pealkirjas.
 */
function fse_theme_search_highlight_title( $title ) {
    if ( ! is_search() || ! in_the_loop() ) return $title;

    $query = get_search_query();
    if ( empty( $query ) ) return $title;

    return preg_replace(
        '/(' . preg_quote( $query, '/' ) . ')/iu',
        '<strong>$1</strong>',
        $title
    );
}
add_filter( 'the_title', 'fse_theme_search_highlight_title' );

Detailid

  • in_the_loop() – kaitse, et navi-menüü, header’i ja muude kohtade pealkirjades ei bold’i. Ainult Query Loop’i sees
  • preg_quote – escape regex erimärke kasutaja sisendis (kui ta otsib “v.*”, ei taha me regex’i tõlgendust)
  • /u flag – UTF-8 mode, vajalik eesti tähtede (Ä, Õ, Ü, Ö) jaoks
  • /i flag – case-insensitive, et “Vundamendi” ja “vundamendi” mõlemad leiaks

Mida kontrollida?

Hard refresh. Vaata otsingulehel pealkirju – kas otsingusõna on neis paksult? Proovi otsinguid eri sõnaga, sh suure ja väikse algustähega.

Samm 4: Bold excerpt’s

NB! See samm on keerulisem kui eelnevad. Eelmistel Samm 1–3 oli muster: Miks → Kuidas → Mida kontrollida. Selles sammus on lisaks kolmas kiht: enne õiget lahendust näeme kaks loomulikku katset, mis ei tööta. See on tahtlik — tutvustab WP filtrite kihtide ehitust. Loe hoolikalt ja ära kiirusta.

Loomulik (aga vale) lähenemine

Sa võiksid arvata: “Eelmine filter töötas pealkirjale (the_title), nüüd lisan sama excerpt’ile (the_excerpt või get_the_excerpt).” Loogika ütleb seda.

Aga see EI TÖÖTA. Miks?

Kaks lõksu järjest

Lõks #1: wp:post-excerpt blokk EI käivita the_excerpt filtrit. Blokk-renderdamise kood kasutab otse get_the_excerpt() funktsiooni. Niisiis kõik the_excerpt filtrid on FSE block-themes-is tähelepanuta.

Lõks #2: Hea, niisiis kasutame get_the_excerpt filtrit. Filter käivitub korralikult ja tagastab tagasi <strong>tekst</strong>. AGA pärast filtrit kutsub blokk wp_trim_words(), mis kasutab seest wp_strip_all_tags()kogu HTML kaob.

Tagajärg: <strong> tag, mille filter sisestas, lihtsalt strip’itakse ära enne renderdamist. Frontend näeb plaint teksti.

Õige lahendus: render_block_core/post-excerpt

FSE blokkide puhul on kõige hilisem filter ehk see, mis käivitub kõige hilisemana, kõige ohutum koht muudatuste tegemiseks. Iga blokk on render_block_core/{block-name} filtri all – see käivitub pärast kogu blokk-renderdamist.

Lisa functions.php-sse:

/**
 * Search: bold otsingusõna wp:post-excerpt bloki HTML-is.
 *
 * Käivitub PÄRAST wp_trim_words() HTML-stripimist.
 * See on ainus koht, kus <strong> säilib.
 */
function fse_theme_search_highlight_excerpt_block( $block_content, $block ) {
    if ( ! is_search() ) return $block_content;

    $query = get_search_query();
    if ( empty( $query ) ) return $block_content;

    return preg_replace(
        '/(' . preg_quote( $query, '/' ) . ')/iu',
        '<strong>$1</strong>',
        $block_content
    );
}
add_filter( 'render_block_core/post-excerpt', 'fse_theme_search_highlight_excerpt_block', 10, 2 );

Mida kontrollida?

Hard refresh. Vaata frontend’s – otsingusõna peaks nüüd olema paks ka excerpt’s. Kui ei – kontrolli source’i (View Page Source), kas <strong> on HTML-is.

Terve functions.php pärast seda sammu (versioon 4 – LÕPLIK)

Lõpuks näeb functions.php välja nii. Muudatused võrreldes versiooniga 3 (#7 lõpus): kolm uut filter-funktsiooni otsingu-tulemuste lehe jaoks:

<?php
/**
 * fse-theme functions.php
 *
 * Theme support + block styles + pattern categories + page excerpt UI +
 * meta-väljade registreerimine + otsingu-filtrid.
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Theme support deklaratsioonid.
 */
function fse_theme_setup() {
    add_theme_support( 'post-thumbnails' );

    add_theme_support( 'html5', [
        'search-form',
        'comment-form',
        'comment-list',
        'gallery',
        'caption',
        'style',
        'script',
    ] );

    add_theme_support( 'wp-block-styles' );

    add_post_type_support( 'page', 'excerpt' );
}
add_action( 'after_setup_theme', 'fse_theme_setup' );

/**
 * Block style: Secondary Button.
 */
function fse_theme_register_block_styles() {
    register_block_style( 'core/button', [
        'name'  => 'secondary',
        'label' => __( 'Secondary', 'fse-theme' ),
    ] );
}
add_action( 'init', 'fse_theme_register_block_styles' );

/**
 * Register ACF meta fields for REST API + Block Bindings.
 */
function fse_theme_register_meta_fields() {
    register_post_meta( 'service', 'price_from', [
        'type'         => 'number',
        'single'       => true,
        'show_in_rest' => true,
    ] );
    register_post_meta( 'service', 'icon', [
        'type'         => 'string',
        'single'       => true,
        'show_in_rest' => true,
    ] );
    register_post_meta( 'service', 'featured', [
        'type'         => 'boolean',
        'single'       => true,
        'show_in_rest' => true,
    ] );

    register_post_meta( 'team', 'job_title', [
        'type'         => 'string',
        'single'       => true,
        'show_in_rest' => true,
    ] );
    register_post_meta( 'team', 'email', [
        'type'         => 'string',
        'single'       => true,
        'show_in_rest' => true,
    ] );
    register_post_meta( 'team', 'linkedin', [
        'type'         => 'string',
        'single'       => true,
        'show_in_rest' => true,
    ] );
}
add_action( 'init', 'fse_theme_register_meta_fields' );

/**
 * UUS: Search – kontekstipõhine excerpt.
 */
function fse_theme_search_contextual_excerpt( $excerpt, $post ) {
    if ( ! is_search() || ! $post instanceof WP_Post ) {
        return $excerpt;
    }

    $query = get_search_query();
    if ( empty( $query ) ) {
        return $excerpt;
    }

    $content = wp_strip_all_tags( $post->post_content );
    if ( empty( $content ) ) {
        return $excerpt;
    }

    $pos = mb_stripos( $content, $query );
    if ( $pos === false ) {
        return $excerpt;
    }

    $start   = max( 0, $pos - 80 );
    $length  = mb_strlen( $query ) + 160;
    $snippet = mb_substr( $content, $start, $length );

    if ( $start > 0 ) {
        $snippet = '… ' . $snippet;
    }
    if ( $start + $length < mb_strlen( $content ) ) {
        $snippet .= ' …';
    }

    return $snippet;
}
add_filter( 'get_the_excerpt', 'fse_theme_search_contextual_excerpt', 10, 2 );

/**
 * UUS: Search – bold otsingusõna excerpt-bloki HTML-is.
 */
function fse_theme_search_highlight_excerpt_block( $block_content, $block ) {
    if ( ! is_search() ) {
        return $block_content;
    }

    $query = get_search_query();
    if ( empty( $query ) ) {
        return $block_content;
    }

    return preg_replace(
        '/(' . preg_quote( $query, '/' ) . ')/iu',
        '<strong>$1</strong>',
        $block_content
    );
}
add_filter( 'render_block_core/post-excerpt', 'fse_theme_search_highlight_excerpt_block', 10, 2 );

/**
 * UUS: Search – bold otsingusõna pealkirjas.
 */
function fse_theme_search_highlight_title( $title ) {
    if ( ! is_search() || ! in_the_loop() ) {
        return $title;
    }

    $query = get_search_query();
    if ( empty( $query ) ) {
        return $title;
    }

    return preg_replace(
        '/(' . preg_quote( $query, '/' ) . ')/iu',
        '<strong>$1</strong>',
        $title
    );
}
add_filter( 'the_title', 'fse_theme_search_highlight_title' );

/**
 * Pattern kategooriad.
 */
function fse_theme_register_pattern_categories() {
    register_block_pattern_category( 'fse-theme-hero', [
        'label' => __( 'FSE Theme – Hero', 'fse-theme' ),
    ] );

    register_block_pattern_category( 'fse-theme-card', [
        'label' => __( 'FSE Theme – Cards', 'fse-theme' ),
    ] );

    register_block_pattern_category( 'fse-theme-section', [
        'label' => __( 'FSE Theme – Sections', 'fse-theme' ),
    ] );
}
add_action( 'init', 'fse_theme_register_pattern_categories' );

See on kogu functions.php evolutsiooni lõpp-punkt projekti praegusele seisule. Edaspidi (nt avalehe Featured Services, single-service.html), võib lisada veel funktsioone, aga need 4 versiooni katavad kõik tähtsamad õppetunnid.

Reegel filtri valimisel FSE blokkidega

See on suur õppetund kogu projektist. Tee selge reegel oma peas:

Tahad muuta…Kasuta filtrit
Sisu enne renderdamistget_the_content, get_the_excerpt, the_title
Renderdatud HTML-i (incl. inline HTML säilumist)render_block_core/{block-name}
Excerpt’i HTML (sealhulgas pärast wp_trim_words)render_block_core/post-excerpt
Vana classic style templatethe_excerpt (FSE-s ei käivitu)

Õpilastele: kui sa lisad HTML-i excerpt-blokis ja see kaob, vahetage filter get_the_excerptrender_block_core/post-excerpt. See on FSE-spetsiifiline lõks, mida vanades teemades polnud.

UTF-8 turvalisus (eesti tähed)

Kuna projekt on eesti keeles, on kohustuslik kasutada UTF-8 turvalisi funktsioone:

  • mb_strlen (mitte strlen) – pikkus tähemärkides, mitte baitides
  • mb_stripos (mitte stripos) – case-insensitive otsing
  • mb_substr (mitte substr) – substring tähemärkides
  • Regex’is /u flag – UTF-8 mode

Ilma nendeta: kui otsid “Õppimine”, saad katki snippet’i (ä, õ, ü on 2-baidised) ja regex käitub valesti.

Veaotsing

Excerpt näitab vaikimisi (mitte snippet)

  • Kontrolli, kas otsingusõna ON sisus (mitte ainult pealkirjas)
  • ddev exec wp cache flush
  • Hard refresh brauseris
  • Kontrolli, kas filter on registreeritud: get_the_excerpt (mitte the_excerpt)

Bold pole excerpt’s näha (aga pealkirjas on)

  • Lõks #2 – wp_trim_words strip’b HTML
  • Lisa render_block_core/post-excerpt filter (Samm 4)

Eesti tähed katki (“Õppimine” annab segase tulemuse)

  • Asenda strlen, stripos, substrmb_strlen, mb_stripos, mb_substr
  • Lisa /u flag regex’i
  • Kontrolli, kas mbstring laiend on PHP-s aktiivne: ddev exec php -m | grep mbstring

Õppetundide kokkuvõte

  • search.html on WP standardne template’i nimi – tee see kausta templates/
  • wp:query-title type="search" kuvab “Search results for: ‘query’”
  • wp:query inherit:true pärib otsingu-päringu URL’ist – ei vaja oma postType
  • wp:query-no-results blokk käsitleb “midagi ei leitud” elegantselt
  • Kontekstipõhine snippet annab kasutajale konteksti, miks tulemus on relevantne
  • FSE-s ei kasuta the_excerpt, kasuta get_the_excerpt
  • HTML-i lisamine excerpt’i: ainult render_block_core/post-excerpt filter säilib
  • UTF-8 turvalisus on eesti keelse projekti puhul kohustuslik

Mis tuleb järgmiseks

Otsingulehe ehitamine paljastas kihti, mis on FSE-arendaja igapäev. Sama muster (sisu-filter vs blokk-renderdamise filter) tuleb tagasi alati, kui sa tahad lisada midagi blokkidesse, mida WP vaikimisi ei luba.

Järgmised loomulikud sammud projektis:

  • page.html template (Meist, Kontakt, Privaatsuspoliitika jaoks – PageHero pattern reference’iga)
  • single-service.html template (üksiku teenuse leht ServiceHero + Body + FAQ)
  • Avaleht Featured Services (Query Loop filtriga meta_query: featured=true)
  • Otsinguvormi lisamine header’isse, et oleks alati kättesaadav

Kõik need õppetunnid on detailselt talletatud project/casestudy.md-s. Soovituslik lugemise järjekord:

Hea õppimist.