Published on

Hugoで検索機能

Authors
  • avatar
    Name
    Kikusan
    Twitter

Hugoサイトの検索機能をLunr.js, Reactによって作成しました。 手順を残します。

事前準備

まず、検索に必要なjsonファイルはhugoの機能で作成します。index.jsonを_default直下に配置し, config.tomlに以下を記入することでindex.jsonが作成されます。

  • config.toml
# ........
[outputs]
  home = ["JSON", "HTML"]
  • index.json
{{ $dateFormat := default "Mon Jan 2, 2006" (index .Site.Params "date_format") }}
{{ $utcFormat := "2006-01-02T15:04:05Z07:00" }}
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "description" .Description "categories" .Params.categories "contents" .Plain "href" .Permalink "utc_time" (.Date.Format $utcFormat) "formated_time" (.Date.Format $dateFormat)) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

リポジトリにjsxフォルダを作成し、ローカルでビルドします。参照

cd jsx
npm init -y
npm install babel-cli@6 babel-preset-react-app@3
npx babel --watch src --out-dir ../static/js --presets react-app/prod

これでjsx配下のファイルは、/static/jsにビルドされて配置されます。

Lunr.js

ココココから以下を持ってきて、/static/js/に配置します。

  • lunr.js.js
  • lunr.js
  • lunr.multi.js
  • lunr.stemmer.support.js
  • tinyseg.js

ページで読み込みます。(私の場合、hugoのhead-custom.htmlファイル)

<script src="/js/lunr.js"></script>
<script src="/js/lunr.stemmer.support.js"></script>
<script src="/js/tinyseg.js"></script>
<script src="/js/lunr.ja.js"></script>
<script src="/js/lunr.multi.js"></script>

Reactで検索の実装

<script src="https://unpkg.com/react@17/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js" crossorigin></script>
<script src="/js/search.js"></script>

こう読みこまれるsearch.jsを、jsxで作成します。(一部jquery使用)

let lunrIndex, pagesIndex;

function initLunr() {
  $.getJSON("/index.json").done((index) => {
      pagesIndex = index;
      lunrIndex = lunr(function() {
        let lunrConfig = this;
        lunrConfig.use(lunr.multiLanguage('en', 'ja'));
        lunrConfig.ref("href");
        lunrConfig.field("title", { boost: 10 });
        lunrConfig.field("contents");
        pagesIndex.forEach((page) => {
          lunrConfig.add(page);
        });
      });
    })
  .fail((jqxhr, textStatus, error) => {
    let err = textStatus + ", " + error;
    console.error("Error getting Hugo index file:", err);
  });
}

function search(){
  let query = document.getElementById('search-query').value;
  if (query.length < 1) {
    return;
  }
  renderResults(results(query));
}

function results(query) {
  // Get the object that matches the search result from the page
  return lunrIndex.search(`*${query}*`).map((result) => {
    return pagesIndex.filter((page) => {
      return page.href === result.ref;
    })[0];
  });
}

function renderResults(results) {
  let eResult = document.querySelector("#blog-main");
  const noMatch = <p>No matches found</p>;
  if (!results.length) {
    ReactDOM.render(noMatch, eResult);
    return;
  }
  ReactDOM.render(<Articles results={results} />, eResult);
}

function Articles(props) {
  const articles = props.results.map((r, i) => 
    <article key={i} className="border-bottom">
      <a className="article-hover d-block p-3 text-decoration-none" href={r.href}>
        <header>
          <h2 className="blog-post-title" dir="auto">{r.title}</h2>
          <p className="blog-post-meta">
            <time dateTime={r.utc_time}>{r.formated_time}</time>
            <Categories categories={r.categories} />
            <Tags tags={r.tags} />
          </p>
        </header>
        <span className="text-muted">{r.description}&nbsp;</span>
        Read more →
      </a>
    </article>
  );

  return (
      <React.Fragment>
        {articles}
      </React.Fragment>
  );
}

function Categories(props) {
  
  const categories = props.categories.map((c, i) => 
    <React.Fragment key={i}>
      {i > 0 &&
        " , "
      }
      <object>
        <a className="text-info" href={`/categories/${c.toLowerCase()}/`} rel="category tag">
          {c}
        </a>
      </object>
    </React.Fragment>
  );

  return (
    <React.Fragment>
      &nbsp;in <span className="fas fa-folder" aria-hidden="true"></span>&nbsp;
      {categories}
    </React.Fragment>
  )
}

function Tags(props) {
  
  const tags = props.tags.map((t, i) => 
    <React.Fragment key={i}>
      {i > 0 &&
        " , "
      }
      <object>
        <a className="badge badge-tag" href={`/tags/${t.toLowerCase()}/`} rel="category tag">
          {t}
        </a>
      </object>
    </React.Fragment>
  );

  return (
    <React.Fragment>
      &nbsp;<span className="fas fa-tag" aria-hidden="true"></span>&nbsp;
      {tags}
    </React.Fragment>
  )
}

initLunr();

initLunrで検索するjsonを読み込んでおき、searchで検索し、レンダリングします。

Articles以下のjsxはこのサイトのテーマに沿っているので、自由にカスタムしてください。