0.0.7 • Published 7 days ago

astro-fuse v0.0.7

Weekly downloads
-
License
MIT
Repository
github
Last release
7 days ago

astro-fuse

This Astro integration generates the fuse.json index file of Fuse.js when your Astro project during build.

Use this plugin to add search functionality to your Astro site.

Installation

First, install the fuse.js and astro-fuse packages using your package manager.

npm install fuse.js astro-fuse

Then, apply this integration to your astro.config.* file using the integrations property:

// astro.config.mjs
import { defineConfig } from 'astro/config'
import fuse from 'astro-fuse'

export default defineConfig({
  // ...
  integrations: [fuse()],
})

Usage

When you install the integration, the Fuse.js index file will be generated when you start the development server.

{"list": [{"pathname": "...", "content": "...", {...}, {...}]}

Now we just need to implement the search UI. Please refer to the Using index file on site section below.

Configuration

In addition to the example described below, you can use various options listed in the Fuse.js official documentation.

keys

You can provide the key values of the properties you want to search in addition to the body.

// astro.config.mjs
// ...
fuse({ keys: ['frontmatter.title'] });

Note If you only want the body to be searched, you don't need to use the keys option.

injectScript

By default, astro-fuse adds a function to the global scope to use the index file generated by the plugin. If you set injectScript to false, the function will not be added to the global scope.

basedOn

  • default 'source': If you set the basedOn option to 'source', the index file will be generated based on the markdown source in the content folder. This mode will update the index in real time in the development environment. In addition, you can search for frontmatters in markdown immediately by adding it to the keys array without any other process.
  • 'output': Setting the basedOn option to 'output' will now generate the index file based on the HTML files generated after the build. This mode will allow you to search for the static rendering results of components used in mdx files.

Since each mode has its own advantages and disadvantages, please use it appropriately according to the situation.

filter (only for output mode)

The path of HTML files to be included in the index can be filtered.

// astro.config.mjs
// ...
fuse({
  basedOn: 'output',
  filter: path => /^\/blog/g.test(path), // index only filtered files
});

extractContentFromHTML (only for output mode)

Setting the basedOn option to 'output' will now generate the index file based on the HTML files generated after the build. This may include unnecessary content such as text in the header area. You can use the extractContentFromHTML option to select the elements that need to be searched.

// astro.config.mjs
// ...
fuse({
  extractContentFromHTML: 'article' // index text inner <article> element.
  extractContentFromHTML: $ => $('div#content') // also you can use cheerio instance.
})

extractFrontmatterFromHTML (only for output mode)

In 'output' mode, the index is generated based on the rendered HTML files, so frontmatter cannot be extracted. If frontmatter is required, you can use the extractFrontmatterFromHTML option to make frontmatter searchable as well.

For example, if you need the original title value because the pathname is sluggified, the following MDX file can be bundled into various path HTML files like /content/2023-08-14-a-page-title.mdx => blog/2023/08/a-page-title.html.

---
title: A Page Title
---

In this situation, the extractFrontmatterFromHTML option can be helpful. If you render the title to the meta[property="og:title"] tag, you can get it with the following options.

// Post.astro

// SUDO-CODE
---
const {frontmatter} = Astro.props;
const {title, description} = frontmatter;
---
<html>
<!-- .. make hidden input for render frontmatter .. -->
<input
    type="hidden"
    data-frontmatter
    value={JSON.stringify({ title, description })}
/>
</html>
// astro.config.mjs

fuse({
  keys: ['content', 'frontmatter.title'],
  basedOn: 'output',
  extractFrontmatterFromHTML: ($) => {
    // read that element value. $ is cheerio instance.
    const el = $('[data-frontmatter]')

    if (el.length) {
      return JSON.parse(el.first().val())
    }

    return { title: $('h1').first().text() }
  },
})

The $ is a Cheerio instance, and you can use it to search for elements. For more information, see the Selecting Elements links. Selecting Elements

Using index file on site

The generated fuse.json file can be used on web pages as follows:

// Please refer to the Fuse.js official API documentation for the arguments of `loadFuse`
const fuse = await loadFuse(options)

const result = fuse.search('search keyword')

astro-fuse adds a function called 'loadFuse' to the global context. Calling this function will give you an instance of Fuse.js that uses the generated index during the build process.

Basic Example

<input type="text" data-search-inp />
<ul data-search-result></ul>

<script>
  import type Fuse from 'fuse.js';
  import type { Searchable } from 'astro-fuse';

  const inp = document.querySelector<HTMLInputElement>('[data-search-inp]');
  const ul = document.querySelector('[data-search-result]');

  let inst: Fuse<Searchable>;

  function load() {
    // for prevent duplicated requests
    if (!inst) {
      return loadFuse().then((_inst) => {
        inst = _inst;
        return inst;
      });
    }

    return Promise.resolve(inst);
  }

  inp?.addEventListener("input", () => {
    load()
      .then((fuse) => fuse.search(inp?.value))
      .then((results) => {
        if (!ul) {
          return;
        }

        // you can manipulate fileUrl to make link URL
        ul.innerHTML = results
          .map(({ item }) => `<li>${item.fileUrl}</li>`)
          .join('');
      });
  });
</script>

Preact Example

export function Search() {
  const fuse = useRef(null)
  const [query, setQuery] = useState('')

  useEffect(() => {
    loadFuse().then((inst) => (fuse.current = inst))
  }, [])

  const list = useMemo(() => {
    if (!query || !fuse.current) {
      return []
    }

    return fuse.current.search(query)
  }, [query])

  return (
    <div>
      <input type="text" onInput={(e) => setQuery(e.target.value)} />
      <ul>
        {list.map((item) => {
          return (
            <li>
              <div>{item.fileUrl}</div>
              <div>{item.content}</div>
            </li>
          )
        })}
      </ul>
    </div>
  )
}

The output format of the search method is as follows (in basedOn: source mode):

Alternatively, you can directly load fuse.json and use it instead of using loadFuse. Below is an example code for that:

astro.config.mjs

import { defineConfig } from 'astro/config';
import fuse from 'astro-fuse'

export default defineConfig({
  // ...
      ,
  integrations: [
    fuse({
      keys: ['content', 'frontmatter.title'],
      injectScript: false
    })
  ],
});
function loadFuseCustom(options) {
  return Promise.all([
    import('fuse.js'),
    fetch('/fuse.json').then((res) => res.json()),
  ]).then(
    ([Fuse, { list, index }]) =>
      new Fuse.default(
        list,
        // Note that the value of keys should be the same as the one passed to astro.config.*
        { keys: ['content', 'frontmatter.title'] },
        Fuse.default.parseIndex(index)
      )
  )
}

Createing fuse.js index based on rendered HTML files

When you use the baseOn: 'output' option, as shown in the code below, the Fuse.js index will be created based on the bundled HTML files in the dist folder.

// astro.config.mjs
import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import fuse from 'astro-fuse'

import sitemap from '@astrojs/sitemap'

// https://astro.build/config
export default defineConfig({
  site: 'https://example.com',
  integrations: [
    mdx(),
    sitemap(),
    fuse({
      basedOn: 'output',
      ignoreLocation: true,
      extractContentFromHTML: 'article',
    }),
  ],
})

The output format of the search method is as follows (in basedOn: output mode):

Search Component

// Search.astro

<input type="text" data-search-inp />
<ul data-search-result></ul>

<script>
  import type Fuse from "fuse.js";
  import type { OutputBaseSearchable } from "astro-fuse";

  const inp = document.querySelector<HTMLInputElement>("[data-search-inp]");
  const ul = document.querySelector("[data-search-result]");

  let inst: Fuse<OutputBaseSearchable>;

  function load() {
    if (!inst) {
      return loadFuse<OutputBaseSearchable>({ ignoreLocation: true }).then(
        (_inst) => {
          inst = _inst;
          return inst;
        }
      );
    }

    return Promise.resolve(inst);
  }

  inp?.addEventListener("input", () => {
    load()
      .then((fuse) => fuse.search(inp?.value))
      .then((results) => {
        if (!ul) {
          return;
        }

        ul.innerHTML = results
          .map(
            ({ item }) => `<li><a href="${item.pathname}">${item.frontmatter.title}</a></li>`)
          .join("");
      });
  });
</script>

Examples

Remarks

  1. In a development environment, the index file for Fuse.js may not be created immediately when the server starts. In this case, you can request a page that uses a Markdown file to trigger the build process. Please note that the file is created immediately in the production build stage. If it is not created, please report an issue.