Cómo agregar Lunr Search a su sitio web de Gatsby
La forma Jamstack de pensar y crear sitios web se está volviendo cada vez más popular.
¿Has probado ya Gatsby, Nuxt o Gridsome (por citar sólo algunos)? Lo más probable es que su primer contacto haya sido un “¡Guau!” momento: muchas cosas se configuran automáticamente y están listas para usar.
Sin embargo, existen algunos desafíos, uno de los cuales es la funcionalidad de búsqueda. Si está trabajando en cualquier tipo de sitio basado en contenido, probablemente se encontrará con la búsqueda y cómo manejarla. ¿Se puede hacer sin ninguna tecnología externa del lado del servidor?
La búsqueda no es una de esas cosas que vienen listas para usar con Jamstack. Se requieren algunas decisiones e implementación adicionales.
Afortunadamente tenemos un montón de opciones que pueden adaptarse más o menos a un proyecto. Podríamos utilizar la potente API de búsqueda como servicio de Algolia. Viene con un plan gratuito que está restringido a proyectos no comerciales con capacidad limitada. Si usamos WordPress con WPGraphQL como fuente de datos, podremos aprovechar la funcionalidad de búsqueda nativa de WordPress y Apollo Client. Raymond Camden exploró recientemente algunas opciones de búsqueda de Jamstack, incluido apuntar un formulario de búsqueda directamente a Google.
En este artículo, crearemos un índice de búsqueda y agregaremos funcionalidad de búsqueda a un sitio web de Gatsby con Lunr, una biblioteca JavaScript liviana que proporciona una búsqueda extensible y personalizable sin la necesidad de servicios externos del lado del servidor. Lo usamos recientemente para agregar “Buscar por nombre de tartán” a nuestro proyecto Gatsby tartanify.com. Absolutamente queríamos una funcionalidad de búsqueda persistente a medida que se escribe, lo que planteaba algunos desafíos adicionales. Pero eso es lo que lo hace interesante, ¿verdad? Discutiré algunas de las dificultades que enfrentamos y cómo las abordamos en la segunda mitad de este artículo.
empezando
En aras de la simplicidad, utilizamos el iniciador del blog oficial de Gatsby. El uso de un iniciador genérico nos permite abstraer muchos aspectos de la creación de un sitio web estático. Si lo estás siguiendo, asegúrate de instalarlo y ejecutarlo:
gatsby new gatsby-starter-blog https://github.com/gatsbyjs/gatsby-starter-blogcd gatsby-starter-bloggatsby develop
Es un blog pequeño con tres publicaciones que podemos ver abriéndolo http://localhost:8000/___graphql
en el navegador.
¿Invertir índice con Lunr.js?
Lunr utiliza un índice invertido a nivel de registro como estructura de datos. El índice invertido almacena la asignación de cada palabra encontrada dentro de un sitio web a su ubicación (básicamente un conjunto de rutas de página). Depende de nosotros decidir qué campos (por ejemplo, título, contenido, descripción, etc.) proporcionar las claves (palabras) para el índice.
Para nuestro ejemplo de blog, decidí incluir todos los títulos y el contenido de cada artículo. Tratar los títulos es sencillo ya que están compuestos únicamente de palabras. Indexar contenido es un poco más complejo. Mi primer intento fue utilizar el rawMarkdownBody
campo. Desafortunadamente, rawMarkdownBody
introduzca algunas claves no deseadas resultantes de la sintaxis de rebajas.
Obtuve un índice “limpio” usando el campo html junto con el paquete striptags (que, como sugiere el nombre, elimina las etiquetas HTML). Antes de entrar en detalles, veamos la documentación de Lunr.
Así es como creamos y completamos el índice Lunr. Usaremos este fragmento en un momento, específicamente en nuestro gatsby-node.js
archivo.
const index = lunr(function () { this.ref('slug') this.field('title') this.field('content') for (const doc of documents) { this.add(doc) }})
documents
es una matriz de objetos, cada uno con una slug
propiedad title
y content
:
{ slug: '/post-slug/', title: 'Post Title', content: 'Post content with all HTML tags stripped out.'}
Definiremos una clave de documento única (la slug
) y dos campos (la title
y content
, o los proveedores de claves). Finalmente agregaremos todos los documentos, uno por uno.
Empecemos.
Creando un índice en gatsby-node.js
Empecemos instalando las bibliotecas que vamos a utilizar.
yarn add lunr graphql-type-json striptags
A continuación, necesitamos editar el gatsby-node.js
archivo. El código de este archivo se ejecuta una vez en el proceso de construcción de un sitio y nuestro objetivo es agregar la creación de índices a las tareas que Gatsby ejecuta durante la construcción.
CreateResolvers
es una de las API de Gatsby que controla la capa de datos GraphQL. En este caso particular, lo usaremos para crear un nuevo campo raíz; Llamémoslo LunrIndex
.
El almacén de datos internos y las capacidades de consulta de Gatsby están expuestos a los solucionadores de campos GraphQL en context.nodeModel
. Con getAllNodes
, podemos obtener todos los nodos de un tipo específico:
/* gatsby-node.js */const { GraphQLJSONObject } = require(`graphql-type-json`)const striptags = require(`striptags`)const lunr = require(`lunr`)exports.createResolvers = ({ cache, createResolvers }) = { createResolvers({ Query: { LunrIndex: { type: GraphQLJSONObject, resolve: (source, args, context, info) = { const blogNodes = context.nodeModel.getAllNodes({ type: `MarkdownRemark`, }) const type = info.schema.getType(`MarkdownRemark`) return createIndex(blogNodes, type, cache) }, }, }, })}
Ahora centrémonos en la createIndex
función. Ahí es donde usaremos el fragmento de Lunr que mencionamos en la última sección.
/* gatsby-node.js */const createIndex = async (blogNodes, type, cache) = { const documents = [] // Iterate over all posts for (const node of blogNodes) { const html = await type.getFields().html.resolve(node) // Once html is resolved, add a slug-title-content object to the documents array documents.push({ slug: node.fields.slug, title: node.frontmatter.title, content: striptags(html), }) } const index = lunr(function() { this.ref(`slug`) this.field(`title`) this.field(`content`) for (const doc of documents) { this.add(doc) } }) return index.toJSON()}
¿Has notado que en lugar de acceder al elemento HTML directamente con const html = node.html
, estamos usando una await
expresión? Eso es porque node.html
aún no está disponible. El complemento gatsby-transformer-remark (utilizado por nuestro iniciador para analizar archivos Markdown) no genera HTML a partir de Markdown inmediatamente al crear los MarkdownRemark
nodos. En cambio, html
se genera de forma diferida cuando se llama al solucionador de campos html en una consulta. En realidad, lo mismo se aplica a los excerpt
que necesitaremos dentro de un momento.
Miremos hacia adelante y pensamos en cómo vamos a mostrar los resultados de la búsqueda. Los usuarios obtienen esperan un enlace a la publicación correspondiente, con su título como texto ancla. Es muy probable que tampoco les importen un extracto breve.
La búsqueda de Lunr devuelve una matriz de objetos que representan documentos coincidentes por ref
propiedad (que es la clave de documento única slug
en nuestro ejemplo). Esta matriz no contiene el título del documento ni el contenido. Por lo tanto, necesitamos almacenar en algún lugar el título de la publicación y el extracto correspondiente a cada slug. Podemos hacerlo dentro de nuestro LunrIndex
como se muestra a continuación:
/* gatsby-node.js */const createIndex = async (blogNodes, type, cache) = { const documents = [] const store = {} for (const node of blogNodes) { const {slug} = node.fields const title = node.frontmatter.title const [html, excerpt] = await Promise.all([ type.getFields().html.resolve(node), type.getFields().excerpt.resolve(node, { pruneLength: 40 }), ]) documents.push({ // unchanged }) store[slug] = { title, excerpt, } } const index = lunr(function() { // unchanged }) return { index: index.toJSON(), store }}
Nuestro índice de búsqueda cambia solo si se modifica una de las publicaciones o se agrega una nueva publicación. No necesitamos reconstruir el índice cada vez que ejecutamos gatsby develop
. Para evitar compilaciones innecesarias, aprovechemos la API de caché:
/* gatsby-node.js */const createIndex = async (blogNodes, type, cache) = { const cacheKey = `IndexLunr` const cached = await cache.get(cacheKey) if (cached) { return cached } // unchanged const json = { index: index.toJSON(), store } await cache.set(cacheKey, json) return json}
Mejora de páginas con el componente de formulario de búsqueda
Ahora podemos pasar al principio de nuestra implementación. Comencemos por crear un componente de formulario de búsqueda.
touch src/components/search-form.js
Opto por una solución sencilla: una entrada de type="search"
, junto con una etiqueta y acompañada de un botón de envío, todo dentro de una etiqueta de formulario con la search
función de punto de referencia.
Agregaremos dos controladores de eventos, handleSubmit
al enviar el formulario y handleChange
al cambiar la entrada de búsqueda.
/* src/components/search-form.js */import React, { useState, useRef } from "react"import { navigate } from "@reach/router"const SearchForm = ({ initialQuery = "" }) = { // Create a piece of state, and initialize it to initialQuery // query will hold the current value of the state, // and setQuery will let us change it const [query, setQuery] = useState(initialQuery) // We need to get reference to the search input element const inputEl = useRef(null) // On input change use the current value of the input field (e.target.value) // to update the state's query value const handleChange = e = { setQuery(e.target.value) } // When the form is submitted navigate to /search // with a query q paramenter equal to the value within the input search const handleSubmit = e = { e.preventDefault() // `inputEl.current` points to the mounted search input element const q = inputEl.current.value navigate(`/search?q=${q}`) } return ( form role="search" onSubmit={handleSubmit} label htmlFor="search-input" style={{ display: "block" }} Search for: /label input ref={inputEl} type="search" value={query} placeholder="e.g. duck" onChange={handleChange} / button type="submit"Go/button /form )}export default SearchForm
¿Has notado que estamos importando navigate
desde el @reach/router
paquete? Esto es necesario ya que ni Gatsby Link/
ni navigate
proporcionan navegación en ruta con un parámetro de consulta. En su lugar, podemos importar @reach/router
(no es necesario instalarlo porque Gatsby ya lo incluye) y usar su navigate
función.
Ahora que hemos creado nuestro componente, agreguémoslo a nuestra página de inicio (como se muestra a continuación) y a la página 404.
/* src/pages/index.js */// unchangedimport SearchForm from "../components/search-form"const BlogIndex = ({ data, location }) = { // unchanged return ( Layout location={location} title={siteTitle} SEO / Bio / SearchForm / // unchanged
Página de resultados de búsqueda
Nuestro SearchForm
componente navega a la /search
ruta cuando se envía el formulario, pero por el momento no hay nada detrás de esta URL. Eso significa que necesitamos agregar una nueva página:
touch src/pages/search.js
Procedí copiando y adaptando el contenido de la index.js
página. Una de las modificaciones esenciales se refiere a la consulta de la página (ver al final del archivo). Reemplazaremos allMarkdownRemark
con el LunrIndex
campo.
/* src/pages/search.js */import React from "react"import { Link, graphql } from "gatsby"import { Index } from "lunr"import Layout from "../components/layout"import SEO from "../components/seo"import SearchForm from "../components/search-form"
// We can access the results of the page GraphQL query via the data propsconst SearchPage = ({ data, location }) = { const siteTitle = data.site.siteMetadata.title // We can read what follows the ?q= here // URLSearchParams provides a native way to get URL params // location.search.slice(1) gets rid of the "?" const params = new URLSearchParams(location.search.slice(1)) const q = params.get("q") || ""
// LunrIndex is available via page query const { store } = data.LunrIndex // Lunr in action here const index = Index.load(data.LunrIndex.index) let results = [] try { // Search is a lunr method results = index.search(q).map(({ ref }) = { // Map search results to an array of {slug, title, excerpt} objects return { slug: ref, ...store[ref], } }) } catch (error) { console.log(error) } return ( // We will take care of this part in a moment )}export default SearchPageexport const pageQuery = graphql` query { site { siteMetadata { title } } LunrIndex }`
Ahora que sabemos cómo recuperar el valor de la consulta y las publicaciones coincidentes, mostremos el contenido de la página. Observe que en la página de búsqueda pasamos el valor de la consulta al SearchForm /
componente a través de los initialQuery
accesorios. Cuando el usuario llega a la página de resultados de búsqueda, su consulta de búsqueda debe permanecer en el campo de entrada.
return ( Layout location={location} title={siteTitle} SEO / {q ? h1Search results/h1 : h1What are you looking for?/h1} SearchForm initialQuery={q} / {results.length ? ( results.map(result = { return ( article key={result.slug} h2 Link to={result.slug} {result.title || result.slug} /Link /h2 p{result.excerpt}/p /article ) }) ) : ( pNothing found./p )} /Layout)
Puede encontrar el código completo en esta bifurcación del blog gatsby-starter y en la demostración en vivo implementada en Netlify.
Widget de búsqueda instantánea
Encontrar la forma más “lógica” y fácil de usar de implementar la búsqueda puede ser un desafío en sí mismo. Pasemos ahora al ejemplo de la vida real de tartanify.com, un sitio web impulsado por Gatsby que reúne más de 5000 patrones de tartán. Dado que los tartán a menudo se asocian con clanes u organizaciones, la posibilidad de buscar un tartán por nombre parece tener sentido.
Creamos tartanify.com como un proyecto paralelo en el que nos sentimos absolutamente libres de experimentar con cosas. No queríamos una página de resultados de búsqueda clásica, sino un "widget" de búsqueda instantánea . A menudo, una determinada palabra clave de búsqueda se corresponde con una serie de resultados; por ejemplo, "Ramsay" viene en seis variaciones. Imaginamos que el widget de búsqueda sería persistente, lo que significa que debería permanecer en su lugar cuando un usuario navega de un tartán coincidente a otro.
Déjame mostrarte cómo lo hicimos funcionar con Lunr. El primer paso para crear el índice es muy similar al ejemplo de gatsby-starter-blog, sólo que más simple:
/* gatsby-node.js */exports.createResolvers = ({ cache, createResolvers }) = { createResolvers({ Query: { LunrIndex: { type: GraphQLJSONObject, resolve(source, args, context) { const siteNodes = context.nodeModel.getAllNodes({ type: `TartansCsv`, }) return createIndex(siteNodes, cache) }, }, }, })}const createIndex = async (nodes, cache) = { const cacheKey = `LunrIndex` const cached = await cache.get(cacheKey) if (cached) { return cached } const store = {} const index = lunr(function() { this.ref(`slug`) this.field(`title`) for (node of nodes) { const { slug } = node.fields const doc = { slug, title: node.fields.Unique_Name, } store[slug] = { title: doc.title, } this.add(doc) } }) const json = { index: index.toJSON(), store } cache.set(cacheKey, json) return json}
Optamos por la búsqueda instantánea, lo que significa que la búsqueda se activa con cualquier cambio en la entrada de búsqueda en lugar de enviar un formulario.
/* src/components/searchwidget.js */import React, { useState } from "react"import lunr, { Index } from "lunr"import { graphql, useStaticQuery } from "gatsby"import SearchResults from "./searchresults"
const SearchWidget = () = { const [value, setValue] = useState("") // results is now a state variable const [results, setResults] = useState([])
// Since it's not a page component, useStaticQuery for quering data // https://www.gatsbyjs.org/docs/use-static-query/ const { LunrIndex } = useStaticQuery(graphql` query { LunrIndex } `) const index = Index.load(LunrIndex.index) const { store } = LunrIndex const handleChange = e = { const query = e.target.value setValue(query) try { const search = index.search(query).map(({ ref }) = { return { slug: ref, ...store[ref], } }) setResults(search) } catch (error) { console.log(error) } } return ( div className="search-wrapper" // You can use a form tag as well, as long as we prevent the default submit behavior div role="search" label htmlFor="search-input" className="visually-hidden" Search Tartans by Name /label input type="search" value={value} onChange={handleChange} placeholder="Search Tartans by Name" / /div SearchResults results={results} / /div )}export default SearchWidget
Están SearchResults
estructurados así:
/* src/components/searchresults.js */import React from "react"import { Link } from "gatsby"const SearchResults = ({ results }) = ( div {results.length ? ( h2{results.length} tartan(s) matched your query/h2 ul {results.map(result = ( li key={result.slug} Link to={`/tartan/${result.slug}`}{result.title}/Link /li ))} /ul / ) : ( pSorry, no matches found./p )} /div)export default SearchResults
Haciéndolo persistente
¿Dónde deberíamos utilizar este componente? Podríamos agregarlo al Layout
componente. El problema es que nuestro formulario de búsqueda se desmontará cuando la página cambie de esa manera. Si un usuario desea explorar todos los tartanes asociados con el clan "Ramsay", deberá volver a escribir su consulta varias veces. Eso no es ideal.
Thomas Weibenfalk ha escrito un excelente artículo sobre cómo mantener el estado entre páginas con el estado local en Gatsby.js. Usaremos la misma técnica, donde la wrapPageElement
API del navegador establece elementos de interfaz de usuario persistentes alrededor de las páginas.
Agreguemos el siguiente código al archivo gatsby-browser.js
. Es posible que necesites agregar este archivo a la raíz de tu proyecto.
/* gatsby-browser.js */import React from "react"import SearchWrapper from "./src/components/searchwrapper"export const wrapPageElement = ({ element, props }) = ( SearchWrapper {...props}{element}/SearchWrapper)
Ahora agreguemos un nuevo archivo de componente:
touch src/components/searchwrapper.js
En lugar de agregar SearchWidget
un componente al Layout
, lo agregaremos al SearchWrapper
y sucederá la magia. ✨
/* src/components/searchwrapper.js */import React from "react"import SearchWidget from "./searchwidget"
const SearchWrapper = ({ children }) = ( {children} SearchWidget / /)export default SearchWrapper
Crear una consulta de búsqueda personalizada
En este punto, comencé a probar diferentes palabras clave, pero rápidamente me di cuenta de que la consulta de búsqueda predeterminada de Lunr podría no ser la mejor solución cuando se usa para búsqueda instantánea.
¿Por qué? Imaginemos que buscamos tartanes asociados al nombre MacCallum. Mientras escribe “MacCallum” letra por letra, esta es la evolución de los resultados:
m
– 2 partidos (Lyon, Jeffrey M, Lyon, Jeffrey M (Caza))ma
- No hay coincidenciasmac
– 1 partido (Brighton Mac Dermotte)macc
- No hay coincidenciasmacca
- No hay coincidenciasmaccal
– 1 partido (MacCall)maccall
– 1 partido (MacCall)maccallu
- No hay coincidenciasmaccallum
– 3 partidos (MacCallum, MacCallum #2, MacCallum de Berwick)
Los usuarios probablemente escribirán el nombre completo y presionarán el botón si ponemos un botón a disposición. Pero con la búsqueda instantánea, es probable que un usuario la abandone antes de tiempo porque puede esperar que los resultados solo puedan limitar las letras que se agregan a la consulta de palabras clave.
Ese no es el único problema. Esto es lo que obtenemos con "Callum":
c
– 3 partidos no relacionadosca
- No hay coincidenciascal
- No hay coincidenciascall
- No hay coincidenciascallu
- No hay coincidenciascallum
- un partido
Puede ver el problema si alguien se da por vencido a la mitad de escribir la consulta completa.
Afortunadamente, Lunr admite consultas más complejas, incluidas coincidencias aproximadas, comodines y lógica booleana (por ejemplo, AND, OR, NOT) para múltiples términos. Todos estos están disponibles mediante una sintaxis de consulta especial, por ejemplo:
index.search("+*callum mac*")
También podríamos recurrir al query
método index para manejarlo programáticamente.
La primera solución no es satisfactoria ya que requiere un mayor esfuerzo por parte del usuario. Usé el index.query
método en su lugar:
/* src/components/searchwidget.js */const search = index .query(function(q) { // full term matching q.term(el) // OR (default) // trailing or leading wildcard q.term(el, { wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING, }) }) .map(({ ref }) = { return { slug: ref, ...store[ref], } })
¿Por qué utilizar la coincidencia de términos completos con comodines? Esto es necesario para todas las palabras clave que "se benefician" del proceso de derivación. Por ejemplo, la raíz de "diferente" es "diferir". Como consecuencia, las consultas con comodines, como differe*
, differen*
o different*
, no generan coincidencias, mientras que las consultas con términos completos y differe
devuelven coincidencias.differen
different
También se pueden utilizar coincidencias aproximadas. En nuestro caso, se permiten únicamente para términos de cinco o más caracteres:
q.term(el, { editDistance: el.length 5 ? 1 : 0 })q.term(el, { wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING,})
La handleChange
función también "limpia" las entradas del usuario e ignora los términos de un solo carácter:
/* src/components/searchwidget.js */ const handleChange = e = { const query = e.target.value || "" setValue(query) if (!query.length) { setResults([]) } const keywords = query .trim() // remove trailing and leading spaces .replace(/*/g, "") // remove user's wildcards .toLowerCase() .split(/s+/) // split by whitespaces // do nothing if the last typed keyword is shorter than 2 if (keywords[keywords.length - 1].length 2) { return } try { const search = index .query(function(q) { keywords // filter out keywords shorter than 2 .filter(el = el.length 1) // loop over keywords .forEach(el = { q.term(el, { editDistance: el.length 5 ? 1 : 0 }) q.term(el, { wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING, }) }) }) .map(({ ref }) = { return { slug: ref, ...store[ref], } }) setResults(search) } catch (error) { console.log(error) }}
Comprobémoslo en acción:
m
- pendientema
– 861 coincidenciasmac
– 600 partidosmacc
– 35 partidosmacca
– 12 partidosmaccal
– 9 partidosmaccall
– 9 partidosmaccallu
– 3 partidosmaccallum
– 3 partidos
La búsqueda de "Callum" también funciona, lo que da como resultado cuatro coincidencias: Callum, MacCallum, MacCallum #2 y MacCallum de Berwick.
Sin embargo, hay un problema más: las consultas con varios términos. Digamos que estás buscando "Loch Ness". Hay dos tartanes asociados con ese término, pero con la lógica OR predeterminada, obtienes un total de 96 resultados. (Hay muchos otros lagos en Escocia).
Terminé decidiendo que una búsqueda AND funcionaría mejor para este proyecto. Desafortunadamente, Lunr no admite consultas anidadas y lo que realmente necesitamos es ( keyword1 OR *keyword*) AND (keyword2 OR *keyword2*
).
Para superar esto, terminé moviendo el bucle de términos fuera del query
método e intersectando los resultados por término. (Por intersección, me refiero a encontrar todas las babosas que aparecen en todos los resultados por palabra clave).
/* src/components/searchwidget.js */try { // andSearch stores the intersection of all per-term results let andSearch = [] keywords .filter(el = el.length 1) // loop over keywords .forEach((el, i) = { // per-single-keyword results const keywordSearch = index .query(function(q) { q.term(el, { editDistance: el.length 5 ? 1 : 0 }) q.term(el, { wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING, }) }) .map(({ ref }) = { return { slug: ref, ...store[ref], } }) // intersect current keywordSearch with andSearch andSearch = i 0 ? andSearch.filter(x = keywordSearch.some(el = el.slug === x.slug)) : keywordSearch }) setResults(andSearch)} catch (error) { console.log(error)}
El código fuente de tartanify.com está publicado en GitHub. Puede ver la implementación completa de la búsqueda Lunr allí.
Pensamientos finales
La búsqueda suele ser una característica no negociable para encontrar contenido en un sitio. La importancia real de la función de búsqueda puede variar de un proyecto a otro. Sin embargo, no hay razón para abandonarlo con el pretexto de que no coincide con el carácter estático de los sitios web Jamstack. Hay muchas posibilidades. Acabamos de hablar de uno de ellos.
Y, paradójicamente, en este ejemplo específico, el resultado fue una mejor experiencia de usuario en todos los aspectos, gracias a que implementar la búsqueda no era una tarea obvia, sino que requería mucha deliberación. Es posible que no hubiéramos podido decir lo mismo con una solución de venta libre.
Deja un comentario