Thoughts .toString()

Astro: Search content with Pagefind-UI


Pagefind is an open-source Javascript library capable of performant search using limited bandwidth, requiring only a little client-side scripting. It is perfect for being used in conjunction with static generation frameworks like Astro. It achieves this by indexing content into UTF-8 binary encoded meta data. There are several recommended ways to get this up and running.

pagefind

Most obviously, we could follow the Installing and running Pagefind at the library’s documentation and install with npm install pagefind or npx pagefind. The “—site” option specifies the directory from which Pagefind should be run, which should be where the site is built. For Astro, that is /dist. And since we want to update this whenever the site is built, we should update the build script at package.json to

/package.jsonshell
1"scripts": {
2    "dev": "astro dev",
3    "build": "astro check && astro build && pagefind --site dist && cp -r dist/pagefind public/",
4    "preview": "astro preview",
5    "astro": "astro"
6},

Note that the third command in the build script copies the generated pagefind files to /public, which Astro recognizes as part of the root directory during dev. So we’ll build the site now with npm run build so it could generate its utility files under /pagefind.

Then we continue with Getting Started with Pagefind and run into our first problem.

/src/components/Pagefind.astrohtml
1<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
2<script src="/pagefind/pagefind-ui.js"></script>
3<div id="search"></div>
4<script>
5    window.addEventListener('DOMContentLoaded', (event) => {
6        new PagefindUI({ element: "#search", showSubResults: true });
7    });
8</script>

Getting Started with Pagefind

In plain Javascript, /pagefind/pagefind-ui.js would have loaded first with the definition for PagefindUI. But it’s loaded as a client-side script and not recognized by Astro. Typescript also complains that it doesn’t know what PagefindUI is and it can’t find the types. So it doesn’t work out of the box and we’ll have to either parse the script file and adapt it to an Astro component, or find a new solution.

astro-pagefind

Before we get our hands dirty, there is supposedly already a drag and drop integration with Pagefind. I’m talking about the package npm install astro-pagefind. Install it and then follow the instructions on its Github repo, adding the integration to astro.config.ts, then adding the <Search /> component.

/astro.config.tsjavascript
1import { defineConfig } from "astro/config";
2import pagefind from "astro-pagefind";
3
4export default defineConfig({
5  build: {
6    format: "file",
7  },
8  integrations: [pagefind()],
9});
/src/layouts/Nav.astro dir-level-fade=1astro
1---
2import Search from "astro-pagefind/components/Search";
3---
4
5<Search id="search" className="pagefind-ui" uiOptions={{ showImages: false }} />

astro-pagefind’s Github repository

This seems to work fine, following the prior workflow. We build to generate the search indices, etc. However, it appears to fail on some finer details. For example, from Pagefind’s docs, we see that it’s possible to specify elements that Pagefind builds the index from using a data-pagefind-body attribute on its tag. Once the attribute is added, Pagefind will ignore all other content besides the descendents of the elements with that attribute. It is then also possible to specify elements to ignore, either when a data-pagefind-body attribute has not been used, or on a child of the prior to exclude a part of it. This is done using the attribute data-pagefind-ignore, or data-pagefind-ignore="all" to recursively ignore all of its children as well.

In my tests, astro-pagefind doesn’t recognize the data-pagefind-ignore. Elements that are supposed to be ignored are still picked up by the search. So if just a site-wide search is desired, then this option works fine. But be aware that some of the fine-tuning features may not work.

@pagefind/default-ui

Finally, we’ll look at a package that seems to be maintained by the creators of Pagefind, or at least a regular contributor. Using npm install @pagefind/default-ui, it becomes possible to import PagefindUI using ES6 syntax, so that Astro could recognize it. This solves the original problem of using the Pagefind script. Now, it becomes…

/src/components/Pagefind.astrojavascript
1import { PagefindUI } from '@pagefind/default-ui'
2import styles from "@pagefind/default-ui/css/ui.css";
3
4<script>
5    window.addEventListener('DOMContentLoaded', (event) => {
6        new PagefindUI({ element: "#search" });
7    });
8</script>

Adapted from @pagefind/default-ui at NPM

Beautiful. However, when we go to build the index, we get an error that looks something like this.

shell
1src/components/PageFind.astro:9:32 - error ts(7016): Could not find a declaration file for module '@pagefind/default-ui'. 'J:/Projects/devblog-astro/node_modules/@pagefind/default-ui/npm_dist/cjs/ui-core.cjs' implicitly has an 'any' type.
2  Try `npm i --save-dev @types/pagefind__default-ui` if it exists or add a new declaration (.d.ts) file containing `declare module '@pagefind/default-ui';`
3
49     import { PagefindUI } from '@pagefind/default-ui';

Luckily, there’s a hint on how to resolve this issue from the developer behind astro-pagefind, which he describes on Github issue #209 that spawned the Pagefind UI package.

Specifically, either we create an env.d.ts file to declare the module for the environment.

/src/env.d.ts dir-level-fade=1typescript
1declare module "@pagefind/default-ui" {
2    declare class PagefindUI {
3        constructor(arg: any);
4    }
5}

Or we add a “module” declaration to the script tag.

/src/components/Pagefind.astrojavascript
1import { PagefindUI } from '@pagefind/default-ui'
2import styles from "@pagefind/default-ui/css/ui.css";
3
4<script type="module" is:inline>
5    window.addEventListener('DOMContentLoaded', (event) => {
6        new PagefindUI({ element: "#search" });
7    });
8</script>

Astro might still complain that the script contains an attribute. It automatically adds an is:inline tag to prevent itself from optimizing it. So we should add it explicitly.

And… now it’s complaining that

shell
1TypeError: The specifier “@pagefind/default-ui” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”

So, nevermind, the env.d.ts works.

After adding some data-pagefind-body attributes around the blog post content, and data-pagefind-ignore around some stylistic elements that I don’t want the search to index, it now works magnificently!

The search result styling is a bit basic. The beauty of Pagefind is being able to get up and running quickly (or it should be, if it weren’t for the issues above). If more customization is needed, it does have an API that generate a JSON object from search, which could be used to dynamically create HTML elements or a syntax tree library like unist. I’ve taken a look at this and the path seems easily viable. But I think the styling is okay as it is, and actually writing content is more important at the beginning stages of a blog than perfecting styling.