Posts by bob(2)

Page 1 of 1
Cool image

Astro collections

Best way to manage your content in Astro

Content collections are the best way to manage content in any Astro project. Using the Content layer API we can define a collection to provide automatic TypeScript type-safety for all of our content.

What are Content Collections?

Let’s say we want to build our personal blog using Astro; using a collection we can turn a bunch of Markdown files (or MDX, Markdoc, YAML, or JSON files) stored locally in our project as the source of our blog content.

NOTE

This API allows us to load local content (aka files in our filesystem), but there are also third-party loaders (our create our own custom loader) to fetch the content from remote sources (think headless CMS).

Defining a Collection

To define a collection, we must create a src/content.config.ts file in your project, which Astro will use to configure your content collections (we can define many collections).

src/content.config.ts
import { z, defineCollection } from 'astro:content'
// Create the collection
export const posts = defineCollection({
// type: 'content', // CAN'T USE THIS WITH LOADERS!
loader: glob({ pattern: ['!_**/*.md','**/*.md'], base: './posts' }),
schema: ({ image }) =>
z.object({
title: z.string(),
author: z.string(),
tags: z.array(z.string()),
description: z.string(),
date: z.date(),
}),
})
// Export the collection
export const collections = {
posts,
}

This file used to go in src/content/config.ts (the old location, Astro API moves fast), but apparently, now it has to be placed in src/content.config.ts. The glob function allows us to do two things:

  • Set the folder for our collection, using base (relative to project root).
  • Exclude some folder/files from being parsed, using the pattern option (!_**/*.md won’t match folders starting with un underscore)

NOTE

We are using TypeScript file (*.ts) to configure our collection, but it’s also possible to use JavaScript (with the .js extension) to define our collection; or even a Michael Jackson file (.mjs).

link test

Cool pic

Pagefind

Search your site

Pagefind is a search library for static websites. It works by indexing the built static files of our site, and adding a JavaScript search API that allows the user to find content with code that runs client-side.

NOTE

Pagefind can be used with Hugo, Eleventy, Jekyll, Next, Astro, SvelteKit, or any other SSG. There’s even an Astro integration, but we’ll take care of it ourselves, from scratch (it’s more fun and educative).

Installing Pagefind

Pagefind is a static binary, written in Rust with no dynamic dependencies, so installing it would be as simple as:

  1. Downloading the release for our platform (currently supported on Windows, macOS, and Linux distributions).
  2. Place it somewhere in our PATH
  3. And voila!

TIP

The official Pagefind site contains excellent and comprehensive instructions for installing Pagefind

Since we are in Node.js, we can take advantage of the wrapper package for this runtime, and run:

npx pagefind --site "public"

Where public is whatever folder your SSG outputs the generated site, but I’m gonna go ahead and add it to my project dependencies so that I can integrate it in my package.json scripts:

npm i pagefind

Building the Index

As we’ve mentioned at the beginning, Pagefind indexes your site after it builds, so let’s build it:

npm run build

That should output our site’s build to a folder named dist. Now let’s use the pagefind command to index that folder:

pagefind --site dist

That will generate a folder named pagefind under our dist folder (so in dist/pagefind) that contains:

  • The indexed content.
  • The JavaScript that the user’s browser will need to search the indexed stuff.
  • Some UI scripts and stylesheets.

TIP

Probably a good idea to add a pagefind entry to your .gitignore; I added also another one for the dist folder.

Updating our build script

So far we need to use several commands to get Pagefind generating an index of the latest version our our site:

  1. Build the site itself: npm run build
  2. Generating the Pagefind index: pagefind --site dist

It’s probably a good idea to combine these two commands, so that when we deploy our site, whatever pipeline runs the npm run build, will also generate the Pagefind stuff. So let’s update the build script in our package.json file:

"scripts": {
"build": "astro build && pagefind --site dist"
}

Running npm run build now, would produce the usual output, plus the following:

[...]
dist/pagefind
[Building search indexes]
Total:
Indexed 1 language
Indexed 45 pages
Indexed 1285 words
Indexed 0 filters
Indexed 0 sorts

If we open the dist folder (where Astro outputs the build), we should see a pagefind folder with our indexed site.

CAUTION

In my case, that didn’t work due to the way my github workflow was written:

- name: Build with Astro
run: |
${{ steps.detect-package-manager.outputs.runner }} astro build \
--site "${{ steps.pages.outputs.origin }}" \
--base "${{ steps.pages.outputs.base_path }}"
working-directory: ${{ env.BUILD_PATH }}

As you can see, it runs the astro command directly, and not our npm run build. So if you don’t want to mess with your pipeline (no biggie, but hey), you can add the following to your build script:

"scripts": {
"pagefind": "pagefind --site dist && cp -r dist/pagefind public",
"build": "astro build && npm run pagefind"
}

That way, after every build, we end up with the pagefind folder in our public directory, and our GitHub pages deployment won’t have trouble finding it.

Bottom line, you can do it your way, but the whole setup needs the pagefind folder available in public folder.

IMPORTANT

Don’t forget to remove pagefind from your .gitignore file.

Adding the Component UI

Assuming that everytime we build our site, Pagefind indexes its content, we need a UI to search and see the results. Pagefind now recommends the Component UI, which gives us declarative web components for common search interfaces.

NOTE

The previous UI, based on pagefind-ui.js and new PagefindUI(...), still exists in the docs, but the Component UI is the recommended path now.

The nice thing about the new system is that the components follow WAI-ARIA best practices, handle visible and assistive text, and are designed to be hard to misuse.

Loading the Component UI

For the Component UI, we load these files:

<link href="/pagefind/pagefind-component-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-component-ui.js" type="module"></script>

In Astro, I added them to my main layout and used import.meta.env.BASE_URL so the paths still work if the site ever lives under a subpath:

---
const pagefindPath = `${import.meta.env.BASE_URL}pagefind/`
---
<link href={`${pagefindPath}pagefind-component-ui.css`} rel="stylesheet" />
<script src={`${pagefindPath}pagefind-component-ui.js`} type="module"></script>

Selecting the Component

After loading the Component UI script and stylesheet, we can choose between a couple of components:

  • Modal search: good when you want a search dialog that opens from the navbar.
  • Searchbox: good when you want a search input with dropdown results.
  • Build your own: good when you want to assemble the input, summary, filters, and results yourself.

For my site, the modal is the best fit:

<pagefind-modal-trigger></pagefind-modal-trigger>
<pagefind-modal></pagefind-modal>

With the modal, Pagefind handles the dialog, focus management, keyboard navigation, and the Cmd+K / Ctrl+K shortcut for us.

Replacing the Custom Search Modal

Before, I had my own search button, my own overlay, and some JavaScript to open and close it. With the Component UI, the markup is enough:

---
const pagefindPath = `${import.meta.env.BASE_URL}pagefind/`
---
<div class="pagefind-search" data-pf-theme="dark">
<pagefind-config bundle-path={pagefindPath}></pagefind-config>
<pagefind-modal-trigger placeholder="Search"></pagefind-modal-trigger>
<pagefind-modal reset-on-close>
<pagefind-modal-header>
<pagefind-input></pagefind-input>
</pagefind-modal-header>
<pagefind-modal-body>
<pagefind-summary></pagefind-summary>
<pagefind-results show-images></pagefind-results>
</pagefind-modal-body>
<pagefind-modal-footer>
<pagefind-keyboard-hints></pagefind-keyboard-hints>
</pagefind-modal-footer>
</pagefind-modal>
</div>

The minimal version would be even smaller:

<pagefind-modal-trigger></pagefind-modal-trigger>
<pagefind-modal></pagefind-modal>

I used the longer version because I wanted to keep result images enabled:

<pagefind-results show-images></pagefind-results>

That’s the Component UI way to enable images in search results.

The pagefind-config component tells the UI where the generated Pagefind files live:

<pagefind-config bundle-path="/pagefind/"></pagefind-config>

In my Astro component, that value comes from BASE_URL:

<pagefind-config bundle-path={pagefindPath}></pagefind-config>

Keeping the Content Indexed

The UI is separate from the indexing markup. My post and project pages still mark their main content with data-pagefind-body:

<div class="post-content" data-pagefind-body>
<Content />
</div>

That tells Pagefind which part of the page should be searchable.

Result Images

For images in search results, I provide explicit Pagefind metadata in the page head:

<meta
data-pagefind-meta="image[content]"
content={post.data.coverImage.cropped.src}
/>
<meta
data-pagefind-meta="image_alt[content]"
content={post.data.coverImage.alt}
/>

One thing to keep in mind is that Pagefind indexes the built site. If images look broken while running the dev server, test with the production build instead:

Terminal window
npm run build
npm run preview

Fixing Result Titles

My search results showed the site title instead of the post title. The reason was subtle: Pagefind’s automatic title metadata comes from the first h1 on the page.

My logo used to be an h1:

<h1 class="anaglyph">生活のバランス</h1>

So Pagefind treated the logo as the page title. The fix was to make the logo a non-heading element:

<span class="anaglyph">生活のバランス</span>

Then I restored the old visual size with CSS:

.anaglyph {
display: inline-block;
font-size: 2em;
font-weight: 900;
line-height: 1.2;
}

Another good option is to explicitly mark the real page title for Pagefind:

<h1 data-pagefind-meta="title">{post.data.title}</h1>

That tells Pagefind exactly what title to use in search results.

Theming the New UI

The Component UI uses CSS custom properties. I scoped them to my search wrapper:

.pagefind-search {
--pf-text: var(--col-text-1);
--pf-text-secondary: var(--col-text-2);
--pf-text-muted: var(--col-text-2);
--pf-background: var(--col-bg);
--pf-border: var(--col-text-3);
--pf-border-focus: var(--col-text-2);
--pf-hover: var(--col-overlay);
--pf-outline-focus: var(--col-accent-1);
--pf-font: 'Mona Sans', sans-serif;
--pf-input-height: 2rem;
--pf-input-font-size: 16px;
--pf-border-radius: 0.3rem;
--pf-modal-backdrop: var(--col-overlay);
--pf-modal-max-width: min(800px, calc(100vw - 2rem));
--pf-modal-max-height: min(80dvh, 800px);
--pf-modal-top: 5rem;
}

This made the Pagefind modal fit the rest of the site’s dark theme.

Fixing Astro ClientRouter Navigation

One issue I ran into was related to Astro’s client-side navigation. The modal worked the first time, but after clicking a search result and navigating to another page, Cmd+K started behaving erratically.

The browser complained with something like:

Failed to execute 'showModal' on 'HTMLDialogElement':
The element is not in a Document.

The problem was that Astro swapped the page DOM, but Pagefind still had references to the old modal internally. So the next time I opened search, Pagefind sometimes tried to open a modal that was no longer in the document.

The fix was to clean up Pagefind before Astro swaps pages:

<script>
if (!document.documentElement.dataset.pagefindCleanupBound) {
document.documentElement.dataset.pagefindCleanupBound = 'true'
document.addEventListener('astro:before-swap', () => {
document.querySelectorAll('pagefind-modal').forEach((modal) => {
if ('close' in modal && typeof modal.close === 'function') {
modal.close()
}
})
const manager = window.PagefindComponents?.getInstanceManager?.()
if (!manager?.getInstanceNames || !manager?.removeInstance) return
for (const name of manager.getInstanceNames()) {
manager.removeInstance(name)
}
})
}
</script>

That closes any open Pagefind modal and clears Pagefind’s cached component instances before Astro swaps the page. The next page then creates fresh modal and trigger references.

Final Check

After adding the UI, rebuild the site:

Terminal window
npm run build

Pagefind should scan the built HTML, find the data-pagefind-body elements, and generate the index. After that, the search trigger should open the modal, Cmd+K / Ctrl+K should work, and the results should show the correct post titles.