Astro's Live Content Collection - Display Webmentions š
by
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 loaderloadCollection
: an async function that will return the entire collection of data or a filtered setloadEntry
: 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.