Astro's Live Content Collection - Display Webmentions šŸš€

by sugardave

Published:

Posted to:

Using Astro's live content collection to display webmentions for blog posts

Tags:

Astro

how-to

IndieWeb

microformats

Today I would like to speak about a new experimental feature available in Astro 5.10 and beyond: live content collections. A live content collection allows you to fetch its data at runtime rather than build time, which is how a regular content collection would normally be used.

Content Collections

A well-known use case for a content collection is retrieving all the blog posts you have written and then rendering the matching post based on path processing in a ā€œslug pageā€. Anyone who has gone through the complete ā€œBuild your first Astro Blogā€ tutorial should be familiar with how that works.

When running your Astro site in static mode, this requires all potential post paths to be either manually specified in the slug page or dynamically built using the getStaticPaths method. In either case, these paths are created at build time and cannot be changed afterwards. This is also true when you use server-side rendering (SSR), but the method for matching a path to a post is slightly different and not part of the goal of this post.

The important thing to realize is that all ā€œregularā€ content collections need to have their matching paths resolved at build time for static sites and all the content must be present at build time when using either static builds or SSR.

Live Content Collections

With live content collections, developers can take more control over how they obtain content for rendering. One thing you cannot do with regular content collections is have them fetch their data on-demand when a page is requested. Live content collections enable this.

For this site, I have been slowly integrating bits and pieces of interesting concepts from the IndieWeb. One of these is ā€œwebmentionsā€. Without going into too much detail, webmentions are based on microformats and enable disparate, unconnected sites and services to ā€œmentionā€ your content and for your site to be able to discover those mentions and process them how you see fit.

Implementation

I am using a custom content loader to fetch webmentions from Webmention.io. I call it webmentionLoader because that seems appropriate. I have defined a Webmention interface based on suggestions from Claude Sonnet 4. This is a naive first pass, so I’m only going to show the parts that I actually needed to get this all to work:

export interface Webmention {
  // Core webmention properties
  id: string;
  source: string; // URL of the page that mentions your content
  target: string; // URL of your content being mentioned
  targetPath: string; // Path of the target URL (for easier filtering)
  ...
}

I also have a helper function to retrieve the webmentions for my domain. An API key is generated for each site you add to Webmention.io.

const getMentionsHTML = async (apiKey: string) => {
  const response = await fetch(
    `https://webmention.io/api/mentions.html?token=${apiKey}`
  );
  if (!response.ok) {
    throw new Error(`Failed to fetch webmentions: ${response.statusText}`);
  }
  return response.text();
};

The actual live content loader function needs to return an object with three required properties:

  • name: a string to represent the name of the loader
  • loadCollection: an async function that will return the entire collection of data or a filtered set
  • loadEntry: an async function that will return a single item in the collection based on a filter

Since I want to customize the display of webmention data, I am using JSDOM to parse the HTML returned from the fetch in loadCollection. Another thing to note is that since webmentions are a many-to-one relation with posts, loadEntry will never be used, so I am only returning an error indicating it is not supported.

export const webmentionLoader = ({
  apiKey
}: {
  apiKey: string;
}): LiveLoader<Webmention> => {
  return {
    name: 'webmention-loader',
    loadCollection: async ({filter}) => {
      const {targetPath} = filter || {targetPath: ''};
      try {
        const webmentions: Webmention[] = await getMentionsHTML(apiKey).then(
          (html) => {
            const dom = new JSDOM(html);
            const doc = dom.window.document;
            const mentions = Array.from(
              doc.querySelectorAll('.h-entry.mention')
            );
            const items = mentions.map((element, index) => {
              // construct a target path from the target URL
              const target = element
                .querySelector('.u-mention-of')
                ?.getAttribute('href') as string;
              const postPath = target ? new URL(target).pathname : '';
              return {
                id: `${postPath}:${index}`,
                data: {
                  author: {
                    name: element.querySelector('.p-author')
                      ?.textContent as string
                  }
                },
                source: element
                  .querySelector('.u-url')
                  ?.getAttribute('href') as string,
                target,
                targetPath: postPath
              };
            });
            if (targetPath) {
              // Filter by targetPath if provided
              return items.filter((item) => item.targetPath === targetPath);
            }
            return items;
          }
        );

        return {
          entries: webmentions.map((mention) => ({
            id: mention.id,
            data: mention
          }))
        };
      } catch (error) {
        return {
          error: new Error(
            `Failed to retrieve webmentions: ${error instanceof Error ? error.message : 'Unknown error'}`
          )
        };
      }
    },
    // there can be multiple webmentions for the same target, so we will never use loadEntry
    loadEntry: async () => {
      return {
        error: new Error(`webmentionLoader does not support loadEntry`)
      };
    }
  };
};

Now, in my slug page I can handle blog posts as a regular content collection just like I’ve been doing and also grab any webmentions that match them.

---
import type {CollectionEntry} from 'astro:content';
import type {LiveDataEntry} from 'astro';
import type {Webmention} from '@lib/webmentionLoader';
import {getCollection, getLiveCollection, render} from 'astro:content';
import MarkdownPostLayout from '@layouts/MarkdownPostLayout.astro';

interface Props {
  post: CollectionEntry<'blog'>;
  postMentions: LiveDataEntry<Webmention>[];
}

const {
  params: {slug},
  url
} = Astro;
const currentPath = url.pathname.endsWith('/')
  ? url.pathname.slice(0, -1)
  : url.pathname;
const [post] = (await getCollection('blog', ({id}) => {
  return id === slug || id.startsWith(`${slug}/`);
})) as Props['post'][];
const frontmatter = post?.data;
const {entries: webmentions, error} = await getLiveCollection('webmentions', {
  targetPath: currentPath
});
const postMentions: Props['postMentions'] = [];

if (error) {
  console.error(`Error fetching webmentions:, ${error}`);
} else if (webmentions && webmentions.length > 0) {
  postMentions.push(
    ...(webmentions as LiveDataEntry<Webmention>[]).filter((mention) => {
      const {source, target} = mention.data;
      if (!target || !source) {
        return false;
      }
      try {
        const targetUrl = new URL(target);
        return targetUrl.pathname === currentPath;
      } catch {
        return target === currentPath;
      }
    })
  );
}
let Content;
if (post) {
  ({Content} = await render(post));
}

export const prerender = false;
---

<MarkdownPostLayout
  frontmatter={frontmatter ?? {}}
  mentions={postMentions.map((m) => m.data)}
>
  {Content && <Content />}
</MarkdownPostLayout>

It may be a little messy, but it gets the job done. If you want to see it in action, why not link to this page from your site and submit a mention to the handy manual submission form? The source URL will be the page where you linked to this page and the target URL will be this page. After a successful submission, reload this page and you should see it displayed above the footer. Hopefully 😁

Wrapping Up

After putting it all together, I can now retrieve and display webmentions associated with any blog post on sugardave.cloud, huzzah! In the future, I hope to implement other appropriate webmention collections for my posts. My next target is the u-in-reply-to type so I can see what others think about my posts. Once I have that, I am sure I will have another blog post about that journey.