So fügen Sie Lunr-Suche zu Ihrer Gatsby Website hinzu

Avatar of Paulina Hetman
Paulina Hetman am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Die Jamstack-Denkweise und der Aufbau von Websites werden immer beliebter.

Haben Sie schon Gatsby, Nuxt oder Gridsome (um nur einige zu nennen) ausprobiert? Wahrscheinlich war Ihr erster Kontakt ein „Wow!“ Moment – so vieles ist automatisch eingerichtet und einsatzbereit. 

Es gibt jedoch einige Herausforderungen, darunter die Suchfunktion. Wenn Sie an einer inhaltsorientierten Website arbeiten, werden Sie wahrscheinlich auf die Suche und deren Handhabung stoßen. Lässt sich das ohne externe serverseitige Technologie bewerkstelligen? 

Suche ist nicht eines dieser Dinge, die mit Jamstack out of the box kommen. Einige zusätzliche Entscheidungen und Implementierungen sind erforderlich.

Glücklicherweise haben wir eine Reihe von Optionen, die mehr oder weniger an ein Projekt angepasst werden können. Wir könnten die leistungsstarke Such-as-a-Service-API von Algolia verwenden. Sie kommt mit einem kostenlosen Plan, der auf nicht-kommerzielle Projekte mit begrenzter Kapazität beschränkt ist. Wenn wir WordPress mit WPGraphQL als Datenquelle verwenden würden, könnten wir die native Suchfunktion von WordPress und Apollo Client nutzen. Raymond Camden hat kürzlich einige Jamstack-Suchoptionen untersucht, darunter auch die direkte Ausrichtung eines Suchformulars auf Google.

In diesem Artikel erstellen wir einen Suchindex und fügen einer Gatsby-Website eine Suchfunktion mit Lunr hinzu, einer leichtgewichtigen JavaScript-Bibliothek, die eine erweiterbare und anpassbare Suche ohne externe, serverseitige Dienste bietet. Wir haben sie kürzlich verwendet, um unserem Gatsby-Projekt tartanify.com die Funktion „Suche nach Tartan-Namen“ hinzuzufügen. Wir wollten unbedingt eine persistente Suchfunktion mit Tipp-Funktion, was einige zusätzliche Herausforderungen mit sich brachte. Aber das macht es ja interessant, oder? Ich werde einige der Schwierigkeiten, mit denen wir konfrontiert waren, und wie wir damit umgegangen sind, in der zweiten Hälfte dieses Artikels besprechen.

Erste Schritte

Der Einfachheit halber verwenden wir den offiziellen Gatsby-Blog-Starter. Die Verwendung eines generischen Starters ermöglicht es uns, viele Aspekte des Erstellens einer statischen Website zu abstrahieren. Wenn Sie mitmachen, stellen Sie sicher, dass Sie ihn installieren und ausführen.

gatsby new gatsby-starter-blog https://github.com/gatsbyjs/gatsby-starter-blog
cd gatsby-starter-blog
gatsby develop

Es ist ein kleiner Blog mit drei Beiträgen, die wir durch Öffnen von https://:8000/___graphql im Browser anzeigen können.

Showing the GraphQL page on the localhost installation in the browser.

Invertierter Index mit Lunr.js 🙃

Lunr verwendet einen Record-Level Inverted Index als seine Datenstruktur. Der invertierte Index speichert die Zuordnung jedes Wortes, das auf einer Website gefunden wird, zu seinem Speicherort (grundsätzlich ein Satz von Seitenpfaden). Es liegt an uns zu entscheiden, welche Felder (z. B. Titel, Inhalt, Beschreibung usw.) die Schlüssel (Wörter) für den Index liefern.

Für unser Blog-Beispiel habe ich beschlossen, alle Titel und den Inhalt jedes Artikels aufzunehmen. Die Arbeit mit Titeln ist unkompliziert, da sie eindeutig aus Wörtern bestehen. Das Indizieren von Inhalten ist etwas komplexer. Mein erster Versuch war, das Feld rawMarkdownBody zu verwenden. Leider führt rawMarkdownBody zu einigen unerwünschten Schlüsseln, die sich aus der Markdown-Syntax ergeben.

Showing an attempt at using markdown syntax for links.

Ich erhielt einen „sauberen“ Index, indem ich das HTML-Feld in Verbindung mit dem Paket striptags (das, wie der Name schon sagt, HTML-Tags entfernt) verwendete. Bevor wir ins Detail gehen, werfen wir einen Blick auf die Lunr-Dokumentation.

So erstellen und füllen wir den Lunr-Index. Wir werden diesen Snippet gleich verwenden, insbesondere in unserer Datei gatsby-node.js.

const index = lunr(function () {
  this.ref('slug')
  this.field('title')
  this.field('content')
  for (const doc of documents) {
    this.add(doc)
  }
})

 documents ist ein Array von Objekten, jedes mit einer slug-, title- und content-Eigenschaft

{
  slug: '/post-slug/',
  title: 'Post Title',
  content: 'Post content with all HTML tags stripped out.'
}

Wir definieren einen eindeutigen Dokumentenschlüssel (die slug) und zwei Felder (den title und content, oder die Schlüssellieferanten). Schließlich fügen wir alle Dokumente nacheinander hinzu.

Legen wir los.

Indexerstellung in gatsby-node.js

Lassen Sie uns zunächst die Bibliotheken installieren, die wir verwenden werden.

yarn add lunr graphql-type-json striptags

Als Nächstes müssen wir die Datei gatsby-node.js bearbeiten. Der Code aus dieser Datei wird einmal während des Erstellungsprozesses einer Website ausgeführt, und unser Ziel ist es, die Indexerstellung zu den Aufgaben hinzuzufügen, die Gatsby beim Erstellen ausführt. 

CreateResolvers ist eine der Gatsby-APIs, die die GraphQL-Datenschicht steuert. In diesem speziellen Fall verwenden wir sie, um ein neues Root-Feld zu erstellen; nennen wir es LunrIndex

Gatsbys interner Datenspeicher und seine Abfragefunktionen sind über Feld-Resolver in GraphQL auf context.nodeModel zugänglich. Mit getAllNodes können wir alle Knoten eines bestimmten Typs abrufen.

/* 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)
        },
      },
    },
  })
}

Konzentrieren wir uns nun auf die Funktion createIndex. Dort werden wir den Lunr-Snippet verwenden, den wir im letzten Abschnitt erwähnt haben. 

/* 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()
}

Haben Sie bemerkt, dass wir anstatt direkt auf das HTML-Element mit  const html = node.html zuzugreifen, einen  await-Ausdruck verwenden? Das liegt daran, dass node.html noch nicht verfügbar ist. Das gatsby-transformer-remark-Plugin (das von unserem Starter zum Parsen von Markdown-Dateien verwendet wird) generiert HTML aus Markdown nicht sofort beim Erstellen der MarkdownRemark-Knoten. Stattdessen wird html verzögert generiert, wenn der HTML-Feld-Resolver in einer Abfrage aufgerufen wird. Dasselbe gilt tatsächlich für den excerpt, den wir in Kürze benötigen werden.

Werfen wir einen Blick voraus und überlegen wir, wie wir Suchergebnisse anzeigen werden. Benutzer erwarten einen Link zum passenden Beitrag mit dessen Titel als Ankertext. Sehr wahrscheinlich werden sie auch einen kurzen Auszug nicht ablehnen.

Lunrs Suche gibt ein Array von Objekten zurück, die übereinstimmende Dokumente über die Eigenschaft ref (die in unserem Beispiel der eindeutige Dokumentenschlüssel slug ist) darstellen. Dieses Array enthält weder den Dokumententitel noch den Inhalt. Daher müssen wir irgendwo den Post-Titel und den Auszug speichern, die zu jedem Slug gehören. Das können wir in unserem LunrIndex wie folgt tun:

/* 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 }
}

Unser Suchindex ändert sich nur, wenn einer der Beiträge geändert oder ein neuer Beitrag hinzugefügt wird. Wir müssen den Index nicht jedes Mal neu erstellen, wenn wir gatsby develop ausführen. Um unnötige Erstellungen zu vermeiden, nutzen wir die Cache-API.

/* 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
}

Seiten mit der Suchformular-Komponente verbessern

Wir können nun zur Frontend-Seite unserer Implementierung übergehen. Beginnen wir mit dem Erstellen einer Suchformular-Komponente.

touch src/components/search-form.js 

Ich wähle eine einfache Lösung: ein Eingabefeld vom Typ search, gekoppelt mit einem Label und begleitet von einem Submit-Button, alles verpackt in einem Formular-Tag mit der Landmarkenrolle search.

Wir fügen zwei Event-Handler hinzu: handleSubmit beim Absenden des Formulars und handleChange bei Änderungen im Suchfeld.

/* 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}
        id="search-input"
        type="search"
        value={query}
        placeholder="e.g. duck"
        onChange={handleChange}
      />
      <button type="submit">Go</button>
    </form>
  )
}
export default SearchForm

Haben Sie bemerkt, dass wir navigate aus dem Paket @reach/router importieren? Das ist notwendig, da weder Gatsbys <Link/> noch navigate die Navigation innerhalb einer Route mit einem Query-Parameter bieten. Stattdessen können wir @reach/router importieren – es ist keine Installation erforderlich, da Gatsby es bereits enthält – und seine navigate-Funktion verwenden.

Jetzt, da wir unsere Komponente erstellt haben, fügen wir sie unserer Startseite (wie unten) und der 404-Seite hinzu.

/* src/pages/index.js */
// unchanged
import SearchForm from "../components/search-form"
const BlogIndex = ({ data, location }) => {
  // unchanged
  return (
    <Layout location={location} title={siteTitle}>
      <SEO title="All posts" />
      <Bio />
      <SearchForm />
      // unchanged

Suchergebnisseite

Unsere SearchForm-Komponente navigiert beim Absenden des Formulars zur Route /search, aber im Moment gibt es nichts hinter dieser URL. Das bedeutet, wir müssen eine neue Seite hinzufügen.

touch src/pages/search.js 

Ich habe den Inhalt der Seite index.js kopiert und angepasst. Eine der wichtigsten Änderungen betrifft die Seitenabfrage (siehe ganz unten in der Datei). Wir ersetzen allMarkdownRemark durch das Feld LunrIndex

/* 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 props
const 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 SearchPage
export const pageQuery = graphql`
  query {
    site {
      siteMetadata {
        title
      }
    }
    LunrIndex
  }
`

Jetzt, da wir wissen, wie wir den Abfragewert und die passenden Beiträge abrufen können, wollen wir den Inhalt der Seite anzeigen. Beachten Sie, dass wir auf der Suchseite den Abfragewert über die initialQuery-Props an die Komponente <SearchForm /> übergeben. Wenn der Benutzer auf der Suchergebnisseite landet, sollte seine Suchanfrage im Eingabefeld verbleiben. 

return (
  <Layout location={location} title={siteTitle}>
    <SEO title="Search results" />
    {q ? <h1>Search results</h1> : <h1>What 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>
        )
      })
    ) : (
      <p>Nothing found.</p>
    )}
  </Layout>
)

Sie finden den vollständigen Code in diesem Fork von gatsby-starter-blog und die Live-Demo, die auf Netlify bereitgestellt wird.

Instant Search Widget

Den logischsten und benutzerfreundlichsten Weg zur Implementierung der Suche zu finden, kann an sich schon eine Herausforderung sein. Wechseln wir nun zum realen Beispiel von tartanify.com – einer Gatsby-basierten Website mit über 5.000 Tartan-Mustern. Da Tartans oft mit Clans oder Organisationen verbunden sind, scheint die Möglichkeit, einen Tartan nach Namen zu suchen, sinnvoll. 

Wir haben tartanify.com als Nebenprojekt erstellt, bei dem wir uns absolut frei fühlen, Dinge auszuprobieren. Wir wollten keine klassische Suchergebnisseite, sondern ein Instant Search „Widget“. Oft entspricht ein bestimmtes Suchwort einer Reihe von Ergebnissen – zum Beispiel gibt es „Ramsay“ in sechs Variationen.  Wir stellten uns vor, dass das Such-Widget persistent sein sollte, das heißt, es sollte an Ort und Stelle bleiben, wenn ein Benutzer von einem passenden Tartan zum nächsten navigiert.

Ich zeige Ihnen, wie wir das mit Lunr zum Laufen gebracht haben.  Der erste Schritt zur Erstellung des Index ist dem Beispiel von gatsby-starter-blog sehr ähnlich, nur einfacher.

/* 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
}

Wir haben uns für Instant Search entschieden, was bedeutet, dass die Suche durch jede Änderung im Suchfeld ausgelöst wird und nicht durch das Absenden eines Formulars.

/* 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
          id="search-input"
          type="search"
          value={value}
          onChange={handleChange}
          placeholder="Search Tartans by Name"
        />
      </div>
      <SearchResults results={results} />
    </div>
  )
}
export default SearchWidget

Die SearchResults sind wie folgt strukturiert:

/* 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>
      </>
    ) : (
      <p>Sorry, no matches found.</p>
    )}
  </div>
)
export default SearchResults

Persistenz herstellen

Wo sollten wir diese Komponente verwenden? Wir könnten sie zur Layout-Komponente hinzufügen. Das Problem ist, dass unser Suchformular dadurch beim Seitenwechsel de-mountet wird. Wenn ein Benutzer alle mit dem Clan „Ramsay“ verbundenen Tartans durchsuchen möchte, muss er seine Abfrage mehrmals neu eingeben. Das ist nicht ideal.

Thomas Weibenfalk hat einen großartigen Artikel über die Beibehaltung des Zustands zwischen Seiten mit lokalem Zustand in Gatsby.js geschrieben. Wir werden die gleiche Technik verwenden, bei der die Browser-API wrapPageElement persistente UI-Elemente um Seiten herum setzt. 

Fügen wir den folgenden Code zu gatsby-browser.js hinzu. Sie müssen diese Datei möglicherweise im Stammverzeichnis Ihres Projekts hinzufügen.

/* gatsby-browser.js */
import React from "react"
import SearchWrapper from "./src/components/searchwrapper"
export const wrapPageElement = ({ element, props }) => (
  <SearchWrapper {...props}>{element}</SearchWrapper>
)

Fügen wir nun eine neue Komponentendatei hinzu.

touch src/components/searchwrapper.js

Anstatt die SearchWidget-Komponente zu Layout hinzuzufügen, fügen wir sie zu SearchWrapper hinzu und die Magie geschieht. ✨

/* src/components/searchwrapper.js */
import React from "react"
import SearchWidget from "./searchwidget"


const SearchWrapper = ({ children }) => (
  <>
    {children}
    <SearchWidget />
  </>
)
export default SearchWrapper

Erstellung einer benutzerdefinierten Suchabfrage

Zu diesem Zeitpunkt begann ich, verschiedene Schlüsselwörter auszuprobieren, erkannte aber sehr schnell, dass die Standard-Suchabfrage von Lunr möglicherweise nicht die beste Lösung für Instant Search ist.

Warum? Stellen Sie sich vor, wir suchen nach Tartans, die mit dem Namen MacCallum verbunden sind. Während wir „MacCallum“ Buchstabe für Buchstabe eingeben, entwickelt sich die Ergebnisdarstellung wie folgt:

  • m – 2 Treffer (Lyon, Jeffrey M, Lyon, Jeffrey M (Hunting))
  • ma – keine Treffer
  • mac – 1 Treffer (Brighton Mac Dermotte)
  • macc – keine Treffer
  • macca – keine Treffer
  • maccal – 1 Treffer (MacCall)
  • maccall – 1 Treffer (MacCall)
  • maccallu – keine Treffer
  • maccallum – 3 Treffer (MacCallum, MacCallum #2, MacCallum of Berwick)

Benutzer würden wahrscheinlich den vollständigen Namen eingeben und auf den Button klicken, wenn wir einen Button zur Verfügung stellen. Aber bei Instant Search wird ein Benutzer wahrscheinlich früh aufgeben, da er davon ausgehen könnte, dass die Ergebnisse nur dann enger werden, wenn weitere Buchstaben zum Schlüsselwort hinzugefügt werden.

 Das ist nicht das einzige Problem. Hier ist, was wir mit „Callum“ bekommen:

  • c – 3 irrelevante Treffer
  • ca – keine Treffer
  • cal – keine Treffer
  • call – keine Treffer
  • callu – keine Treffer
  • callum – ein Treffer 

Sie können das Problem erkennen, wenn jemand auf halbem Weg aufgibt, die vollständige Abfrage einzugeben.

Glücklicherweise unterstützt Lunr komplexere Abfragen, einschließlich Fuzzy-Matches, Wildcards und Boolescher Logik (z. B. AND, OR, NOT) für mehrere Begriffe. All diese sind entweder über eine spezielle Abfragesyntax verfügbar, zum Beispiel: 

index.search("+*callum mac*")

Wir könnten auch auf die query-Methode des Index zurückgreifen, um dies programmgesteuert zu handhaben.

Die erste Lösung ist nicht zufriedenstellend, da sie mehr Aufwand vom Benutzer verlangt. Ich habe stattdessen die index.query-Methode verwendet.

/* 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],
    }
  })

Warum die vollständige Begriffübereinstimmung mit Wildcard-Übereinstimmung verwenden? Das ist notwendig für alle Schlüsselwörter, die von dem Stemming-Prozess profitieren. Zum Beispiel ist der Stamm von „different“ „differ“.  Infolgedessen führen Abfragen mit Wildcards – wie differe*, differen* oder  different* – alle zu keinen Treffern, während die vollständigen Begriffabfragen differe, differen und different Treffer zurückgeben.

Fuzzy-Matches können ebenfalls verwendet werden. In unserem Fall sind sie nur für Begriffe ab fünf Zeichen erlaubt.

q.term(el, { editDistance: el.length > 5 ? 1 : 0 })
q.term(el, {
  wildcard:
    lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING,
})

Die Funktion handleChange „bereinigt“ auch Benutzereingaben und ignoriert einstellige Begriffe.

/* 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)
  }
}

Schauen wir es uns in Aktion an.

  • m – ausstehend
  • ma – 861 Treffer
  • mac – 600 Treffer
  • macc – 35 Treffer
  • macca – 12 Treffer
  • maccal – 9 Treffer
  • maccall – 9 Treffer
  • maccallu – 3 Treffer
  • maccallum – 3 Treffer

Auch die Suche nach „Callum“ funktioniert und liefert vier Treffer: Callum, MacCallum, MacCallum #2 und MacCallum of Berwick.

Es gibt jedoch noch ein weiteres Problem: Multi-Term-Abfragen. Sagen Sie, Sie suchen nach „Loch Ness.“ Es gibt zwei Tartans, die mit diesem Begriff verbunden sind, aber mit der standardmäßigen OR-Logik erhalten Sie insgesamt 96 Ergebnisse. (Es gibt viele andere Seen in Schottland.)

Ich kam zu dem Schluss, dass eine AND-Suche für dieses Projekt besser funktionieren würde. Leider unterstützt Lunr keine verschachtelten Abfragen, und was wir tatsächlich brauchen, ist (keyword1 OR *keyword*) AND (keyword2 OR *keyword2*). 

Um dies zu überwinden, habe ich die Begriffsschleife außerhalb der query-Methode verschoben und die Ergebnisse pro Begriff geschnitten. (Mit schneiden meine ich, alle Slugs zu finden, die in allen Ergebnissen pro Einzelbegriff vorkommen.)

/* 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)
}

Der Quellcode für tartanify.com ist auf GitHub veröffentlicht. Die vollständige Implementierung der Lunr-Suche finden Sie dort.

Abschließende Gedanken

Suche ist oft ein nicht verhandelbares Merkmal, um Inhalte auf einer Website zu finden. Wie wichtig die Suchfunktion tatsächlich ist, kann von Projekt zu Projekt variieren. Dennoch gibt es keinen Grund, sie unter dem Vorwand aufzugeben, dass sie nicht zum statischen Charakter von Jamstack-Websites passt. Es gibt viele Möglichkeiten. Wir haben gerade eine davon besprochen.

Und paradoxerweise war in diesem speziellen Beispiel das Ergebnis eine insgesamt bessere Benutzererfahrung, da die Implementierung der Suche keine offensichtliche Aufgabe war, sondern viel Überlegung erforderte. Dasselbe könnten wir bei einer über den Ladentisch verkauften Lösung vielleicht nicht sagen.