Browse Source

Search: merge TIL (Docusaurus) results + two-column highlighted layout

- Add a 'TIL' column to the WP search page backed by the Docusaurus site's own
  prebuilt lunr 2.3.9 index. js/til-search.js loads /til/lunr-index.json +
  /til/search-doc.json and queries them exactly like docusaurus-lunr-search's
  adapter (same tokenizer separator, term boost, trailing wildcard), rendering a
  match-centered, <mark>-highlighted snippet list into #dw-til-results. lunr is
  vendored (js/vendor/lunr.min.js). Enqueued only on is_search().
  Self-contained/removable: delete the two js files + the marked blocks in
  functions.php, templates/search.html, and style.css.
- Lay the WP results and TIL results side by side (flex; search box full-width on
  top, columns stack on mobile), drop the 'From TIL' heading, and tighten the WP
  result rows to match the TIL rhythm.
- Highlight the search term in the WP results too: dw_search_excerpt_highlight
  hooks render_block_core/post-excerpt (after the block's wp_trim_words strips
  tags) to swap in a match-centered snippet with <mark> terms. Server-side and
  independent of the TIL feature; excerpts only, never titles. Styled by
  body.search mark.
windhamdavid 2 weeks ago
parent
commit
91f96049ca
6 changed files with 245 additions and 2 deletions
  1. 76 0
      functions.php
  2. 99 0
      js/til-search.js
  3. 5 0
      js/vendor/lunr.min.js
  4. 59 0
      style.css
  5. 6 2
      templates/search.html
  6. 0 0
      v4-style.min.css

+ 76 - 0
functions.php

@@ -132,6 +132,69 @@ function dw_guestbook_render_block( $block_content, $block ) {
 }
 }
 
 
 add_action( 'wp_enqueue_scripts', 'dw_scripts' );
 add_action( 'wp_enqueue_scripts', 'dw_scripts' );
+
+// On the search results page, rewrite each post-excerpt block to a snippet
+// centered on the match with the term(s) highlighted — mirroring the TIL column.
+// Hooked at render_block (not get_the_excerpt) because the core post-excerpt block
+// runs wp_trim_words() → wp_strip_all_tags() after the excerpt filters, which would
+// otherwise eat the <mark> tags. Titles are left alone (never marks <title>/nav).
+// When the term isn't in the body (title-only match) the block is left untouched.
+// Styled by `body.search mark`.
+function dw_search_excerpt_highlight( $block_content, $block, $instance ) {
+	if ( is_admin() || ! is_search() ) {
+		return $block_content;
+	}
+	$query = trim( get_search_query() );
+	if ( '' === $query ) {
+		return $block_content;
+	}
+	$post_id = isset( $instance->context['postId'] ) ? $instance->context['postId'] : 0;
+	$post    = $post_id ? get_post( $post_id ) : null;
+	if ( ! $post instanceof WP_Post ) {
+		return $block_content;
+	}
+
+	$terms  = array_values( array_unique( array_filter( preg_split( '/\s+/', $query ) ) ) );
+	$has_mb = function_exists( 'mb_stripos' );
+
+	// Plain-text body (strip shortcodes, block markup, tags).
+	$text = html_entity_decode(
+		wp_strip_all_tags( strip_shortcodes( $post->post_content ) ),
+		ENT_QUOTES,
+		'UTF-8'
+	);
+	$text = trim( preg_replace( '/\s+/', ' ', $text ) );
+
+	// Locate the first term; bail (leave the intro excerpt) if it's not in the body.
+	$pos = false;
+	foreach ( $terms as $t ) {
+		$p = $has_mb ? mb_stripos( $text, $t ) : stripos( $text, $t );
+		if ( false !== $p ) { $pos = $p; break; }
+	}
+	if ( false === $pos || '' === $text ) {
+		return $block_content;
+	}
+
+	// Window a snippet around the match, snapped to word boundaries.
+	$len   = $has_mb ? mb_strlen( $text ) : strlen( $text );
+	$start = max( 0, $pos - 60 );
+	$slice = $has_mb ? mb_substr( $text, $start, 220 ) : substr( $text, $start, 220 );
+	if ( $start > 0 )          { $slice = preg_replace( '/^\S*\s/', '', $slice ); }
+	if ( $start + 220 < $len ) { $slice = preg_replace( '/\s\S*$/', '', $slice ); }
+	$snippet = esc_html( ( $start > 0 ? '… ' : '' ) . $slice . ( $start + 220 < $len ? ' …' : '' ) );
+	foreach ( $terms as $t ) {
+		$snippet = preg_replace( '/(' . preg_quote( $t, '/' ) . ')/i', '<mark>$1</mark>', $snippet );
+	}
+
+	// Swap the excerpt paragraph's inner content, preserving the block wrapper.
+	return preg_replace_callback(
+		'#(<p class="wp-block-post-excerpt__excerpt">).*?(</p>)#s',
+		function ( $m ) use ( $snippet ) { return $m[1] . $snippet . $m[2]; },
+		$block_content,
+		1
+	);
+}
+add_filter( 'render_block_core/post-excerpt', 'dw_search_excerpt_highlight', 10, 3 );
 function dw_scripts() {
 function dw_scripts() {
 	global $post;
 	global $post;
 	wp_enqueue_style( 'style-min', get_template_directory_uri() . '/v4-style.min.css', array(), filemtime( get_template_directory() . '/v4-style.min.css' ) );
 	wp_enqueue_style( 'style-min', get_template_directory_uri() . '/v4-style.min.css', array(), filemtime( get_template_directory() . '/v4-style.min.css' ) );
@@ -211,6 +274,19 @@ function dw_scripts() {
 			wp_enqueue_style( 'lychee-embed', 'https://davidwindham.com/photo/embed/lychee-embed.css', array(), '1.0.0' );
 			wp_enqueue_style( 'lychee-embed', 'https://davidwindham.com/photo/embed/lychee-embed.css', array(), '1.0.0' );
 			wp_enqueue_script( 'lychee-embed', 'https://davidwindham.com/photo/embed/lychee-embed.js', array(), '1.0.0', true );
 			wp_enqueue_script( 'lychee-embed', 'https://davidwindham.com/photo/embed/lychee-embed.js', array(), '1.0.0', true );
 		}
 		}
+		// --- TIL search (removable): merge the Docusaurus /til lunr results into the
+		// WP search-results page. See js/til-search.js for the full removal checklist. ---
+		if ( is_search() ) {
+			wp_enqueue_script( 'lunr', get_template_directory_uri() . '/js/vendor/lunr.min.js', array(), '2.3.9', true );
+			wp_enqueue_script( 'dw-til-search', get_template_directory_uri() . '/js/til-search.js', array( 'lunr' ), filemtime( get_template_directory() . '/js/til-search.js' ), true );
+			wp_localize_script( 'dw-til-search', 'dwTilSearch', array(
+				'query'   => get_search_query(),
+				'index'   => home_url( '/til/lunr-index.json' ),
+				'docs'    => home_url( '/til/search-doc.json' ),
+				'maxHits' => 10,
+			) );
+		}
+		// --- end TIL search ---
 		wp_enqueue_script( '_s_backbone-loop', get_template_directory_uri() . '/js/loop.js', array( 'jquery', 'backbone', 'underscore', 'wp-api'  ), '1.0', true );
 		wp_enqueue_script( '_s_backbone-loop', get_template_directory_uri() . '/js/loop.js', array( 'jquery', 'backbone', 'underscore', 'wp-api'  ), '1.0', true );
 		$queried_object = get_queried_object();
 		$queried_object = get_queried_object();
 		$local = array(
 		$local = array(

+ 99 - 0
js/til-search.js

@@ -0,0 +1,99 @@
+/* ============================================================================
+ * TIL search — merges the Docusaurus TIL site's results into the WordPress
+ * search-results page.
+ *
+ * The TIL site (/til/, a separate Docusaurus build) uses docusaurus-lunr-search,
+ * which ships a prebuilt lunr 2.3.9 index (/til/lunr-index.json) + a document
+ * store (/til/search-doc.json). This module loads both client-side and queries
+ * them exactly the way the TIL search bar does, so the results match, then
+ * renders a labeled "From TIL" section on the WP search page.
+ *
+ * SELF-CONTAINED / REMOVABLE. Depends only on the vendored global `lunr`
+ * (js/vendor/lunr.min.js) and config in window.dwTilSearch (localized in
+ * functions.php). Fills #dw-til-results (placeholder in templates/search.html).
+ * To remove the feature entirely, delete:
+ *   - this file + js/vendor/lunr.min.js
+ *   - the "TIL search" enqueue block in functions.php (is_search())
+ *   - the <!-- wp:html --> #dw-til-results block in templates/search.html
+ *   - the "TIL search results" CSS block in style.css
+ * ==========================================================================*/
+(function () {
+  'use strict';
+
+  var cfg = window.dwTilSearch;
+  if (!cfg || !cfg.query || typeof lunr === 'undefined') return;
+  var mount = document.getElementById('dw-til-results');
+  if (!mount) return;
+
+  // docusaurus-lunr-search overrides the tokenizer separator; match it so our
+  // client-side tokenizing (and therefore the matches) line up with the index.
+  lunr.tokenizer.separator = /[\s\-/]+/;
+
+  Promise.all([
+    fetch(cfg.index).then(function (r) { return r.json(); }),
+    fetch(cfg.docs).then(function (r) { return r.json(); })
+  ]).then(function (res) {
+    var index = lunr.Index.load(res[0]);
+    var docs = (res[1] && res[1].searchDocs) || [];
+    var input = String(cfg.query);
+
+    // Same query the plugin's LunrSearchAdapter builds: exact terms (boosted)
+    // plus trailing-wildcard terms for prefix matches.
+    var results = index.query(function (query) {
+      var tokens = lunr.tokenizer(input);
+      query.term(tokens, { boost: 10 });
+      query.term(tokens, { wildcard: lunr.Query.wildcard.TRAILING });
+    });
+
+    // Map hits to docs, dedupe by URL (a page can match on title + content),
+    // keep lunr's ranking order, cap to maxHits.
+    var seen = {}, hits = [];
+    for (var i = 0; i < results.length && hits.length < cfg.maxHits; i++) {
+      var doc = docs[results[i].ref];
+      if (!doc || seen[doc.url]) continue;
+      seen[doc.url] = 1;
+      hits.push(doc);
+    }
+    render(hits, input);
+  }).catch(function () {
+    /* TIL index unreachable (e.g. /til not deployed) — skip silently. */
+  });
+
+  function esc(s) {
+    return String(s).replace(/[&<>"]/g, function (c) {
+      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
+    });
+  }
+
+  // A short excerpt centered on the first query term, with the terms highlighted.
+  function snippet(content, input) {
+    if (!content) return '';
+    var tokens = lunr.tokenizer(input).map(String);
+    var lc = content.toLowerCase();
+    var pos = tokens.length ? lc.indexOf(tokens[0]) : -1;
+    var start = pos < 0 ? 0 : Math.max(0, pos - 60);
+    var slice = content.substring(start, start + 200);
+    var out = (start > 0 ? '… ' : '') + esc(slice) +
+              (start + 200 < content.length ? ' …' : '');
+    tokens.forEach(function (t) {
+      if (!t) return;
+      var re = new RegExp('(' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig');
+      out = out.replace(re, '<mark>$1</mark>');
+    });
+    return out;
+  }
+
+  function render(hits, input) {
+    if (!hits.length) { mount.hidden = true; return; }
+    var html = '<ul class="dw-til-list">';
+    hits.forEach(function (d) {
+      html += '<li class="dw-til-item">' +
+                '<a class="dw-til-link" href="' + esc(d.url) + '">' + esc(d.title) + '</a>' +
+                '<p class="dw-til-snippet">' + snippet(d.content, input) + '</p>' +
+              '</li>';
+    });
+    html += '</ul>';
+    mount.innerHTML = html;
+    mount.hidden = false;
+  }
+})();

File diff suppressed because it is too large
+ 5 - 0
js/vendor/lunr.min.js


+ 59 - 0
style.css

@@ -1339,6 +1339,65 @@ body.error404 main > .wp-block-heading {
 	max-width: 1280px;
 	max-width: 1280px;
 	margin: 1.25rem auto 0;
 	margin: 1.25rem auto 0;
 }
 }
+/* ===== Search results: two-column WP | TIL layout ========================= */
+/* The search card lays the WP results and the TIL results (#dw-til-results,
+   filled by js/til-search.js) side by side, with the search box full-width on
+   top. If the TIL feature is removed, #dw-til-results simply isn't rendered and
+   the WP column grows to fill the row. */
+body.search main > .wp-block-group {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: flex-start;
+	gap: 0 3rem;
+}
+body.search main > .wp-block-group > .wp-block-search {
+	flex: 1 1 100%;
+	margin-bottom: 2.5rem;
+}
+body.search main > .wp-block-group > .wp-block-query,
+body.search main > .wp-block-group > #dw-til-results {
+	flex: 1 1 300px;
+	min-width: 0;
+	margin: 0;
+}
+/* Tight, uniform result rows in both columns (was the block's spacing-40 pad). */
+body.search .wp-block-post-template {
+	margin: 0;
+	padding: 0;
+}
+body.search li.wp-block-post {
+	margin: 0 0 1.5rem;
+}
+body.search li.wp-block-post > .wp-block-group {
+	padding: 0;
+}
+body.search .wp-block-post-title {
+	font-size: 1.35rem;
+	font-weight: 700;
+	line-height: 1.25;
+	margin: 0;
+}
+body.search .wp-block-post-excerpt {
+	margin: .35rem 0 0;
+}
+body.search .wp-block-post-excerpt__excerpt {
+	margin: 0;
+	color: #333;
+}
+/* Search-term highlight in both columns — WP excerpts (dw_highlight_search_excerpt)
+   and TIL snippets (js/til-search.js). */
+body.search mark {
+	background: #ffe08a;
+	color: inherit;
+	padding: 0 2px;
+}
+/* --- TIL column (removable — see js/til-search.js) --- */
+.dw-til-results { margin: 0; }
+.dw-til-list { list-style: none; margin: 0; padding: 0; }
+.dw-til-item { margin: 0 0 1.5rem; }
+.dw-til-link { font-size: 1.35rem; font-weight: 700; line-height: 1.25; }
+.dw-til-snippet { margin: .35rem 0 0; color: #333; }
+/* ===== end search results two-column ===================================== */
 /* Pagination — classic archives (.page-numbers from dw_paging_nav) + the block
 /* Pagination — classic archives (.page-numbers from dw_paging_nav) + the block
    query pagination (search/archive). Centered, de-Bootstrapped pill links. */
    query pagination (search/archive). Centered, de-Bootstrapped pill links. */
 .pagination,
 .pagination,

+ 6 - 2
templates/search.html

@@ -9,8 +9,8 @@
 		<!-- wp:query {"queryId":0,"query":{"inherit":true,"perPage":10},"layout":{"type":"constrained"}} -->
 		<!-- wp:query {"queryId":0,"query":{"inherit":true,"perPage":10},"layout":{"type":"constrained"}} -->
 		<div class="wp-block-query">
 		<div class="wp-block-query">
 			<!-- wp:post-template -->
 			<!-- wp:post-template -->
-			<!-- wp:group {"style":{"spacing":{"padding":{"top":"var:preset|spacing|40","bottom":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
-			<div class="wp-block-group" style="padding-top:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--40)">
+			<!-- wp:group {"layout":{"type":"constrained"}} -->
+			<div class="wp-block-group">
 				<!-- wp:post-title {"level":2,"isLink":true} /-->
 				<!-- wp:post-title {"level":2,"isLink":true} /-->
 				<!-- wp:post-excerpt /-->
 				<!-- wp:post-excerpt /-->
 			</div>
 			</div>
@@ -28,6 +28,10 @@
 			<!-- /wp:query-no-results -->
 			<!-- /wp:query-no-results -->
 		</div>
 		</div>
 		<!-- /wp:query -->
 		<!-- /wp:query -->
+		<!-- TIL search (removable): filled by js/til-search.js with Docusaurus /til hits -->
+		<!-- wp:html -->
+		<section id="dw-til-results" class="dw-til-results" hidden aria-live="polite"></section>
+		<!-- /wp:html -->
 	</div>
 	</div>
 	<!-- /wp:group -->
 	<!-- /wp:group -->
 </main>
 </main>

File diff suppressed because it is too large
+ 0 - 0
v4-style.min.css


Some files were not shown because too many files changed in this diff