sc
Scott avatar
ttwillsey

Map Your Stuff

Part of the Astro series

One of the patterns you’ll see frequently in Astro is using the JavaScript array map function. Array.map() creates a new array that holds the results of performing whatever function you provide on each element of the original array.

Ok, that’s clear as mud.

But let’s say you have a podcast. Let’s say this podcast is called Friends with Beer, and let’s say you have a json file full of information about the beer you drink on your podcast. Let’s say it looks like this, repeated n number of times where n is the number of beers you’ve had on the podcast.

beer.json
[
{
"id": "OShp7ovkwb6F14mpRqFbw",
"name": "Hell or High Watermelon",
"brewery": "21st Amendment Brewery",
"image": "21stAmendmentBreweryHellOrHighWatermelon-EA669A2C-D404-422C-8495-AA268674CAA5",
"sortOrder": "0",
"episodes": ["14"],
"url": "https://www.21st-amendment.com/beers/hell-or-high-watermelon",
"rating": [
{
"host": "Scott",
"vote": "thumbs-up",
"description": "I wish it had more watermelon flavor, but it is a nice light wheat beer that's very pleasant."
}
]
},
...
]

Presumably you’d like to show the latest episode on your site’s home page with a little view featuring the beer that was consumed on that episode, like this:

Latest episode beer list view

First thing you need to do is grab the json file and find all beer associated with whatever episode is the latest. I have a file named beerlist.mjs that exports a beerList function this because I want to be able to get a beer list in other places on the site too.

beerList.mjs
import beer from "../../data/beer.json";
export function beerList(episode) {
const ep = episode ?? 0;
let beers = Array.from(beer);
return ep === 0
? beers
: beers.filter((beer) => beer.episodes.includes(String(episode)));
}

I can optionally pass in an episode number to filter the list by. If I do, I return an array of the episode-filtered beers. If no episode number is provided, I just return an array of the full list of beers.

Now I can create that view from the image above by importing my function and using it like this:

BeerList.astro
---
import { Icon } from "astro-icon/components";
import { Image } from "@astrojs/image/components";
import { beerList } from "./utilities/beerlist.mjs";
const { episode } = Astro.props;
const beers = beerList(episode);
---
<div class="beer-container">
{
beers.map((beer) => (
<div class="beer">
<div class="beer-image">
<a href={`/images/beer/${beer.image}.png`}>
<Image
src={`/images/beer/${beer.image}.png`}
width="300"
aspectRatio="1:1"
format="webp"
alt={`${beer.brewery} ${beer.name}`}
/>
</a>
</div>
<div class="beer-name">
<div class="brewery">{beer.brewery}</div>
<div>
<a href={`/bottle/${beer.id}`}>{beer.name}</a>
</div>
<div class="beer-details">
<span>
<Icon name="fluent:info-24-filled" />
</span>
<span>
<a href={`/bottle/${beer.id}`}>View Details</a>
</span>
</div>
</div>
</div>
))
}
</div>

The fun part is everything inside the map function. As you can see, my beerList function returns an array of beers. I map that so that for each beer in the array, I output the HTML inside the map function. This consists of an image of the beer, the brewery name, the beer name, and a link to view the page for that beer.

You can also make your maps more legible by creating a component to use inside the map. Here’s an example from the code for the paginated blog posts on this site that uses a Post component to do all the rendering of each post, just passing the individual mapped post to the component. It looks neater and is easier to understand, but it means creating another component. It just depends how much you want to break things down into separate components. If you need to show posts in a similar manner elsewhere besides the paginated list, you may want to do it by mapping your array items as props to a separate component, like below.

[page].astro
<Base title="test">
<section aria-label="Post list">
{
posts &&
posts.map((post) => {
return (
<Post content={post}>
<post.content />
</Post>
);
})
}
<Pager page={page} , pageSize={pageSize} />
</section>
</Base>

The Astro docs have a good example of using the map function in the “Converting markdown to MDX” guide and (more usefully for most people) the Astro.glob function documentation.

By the way, if you’re wondering why I treat episode number as a number sometimes and treat it like a string other times (inside beer.json, for example), rest assured you aren’t the only one wondering. I took that json file from my existing Eleventy site for Friends with Beer and didn’t think much about it. Refinements are certainly a valid consideration.

This Is a Blog

This is a blog.

After reading that, you may be thinking “Omg, he’s hit his head and now he’s reduced to touching everything he sees and saying its name out loud”, but here’s what I’m getting at: it’s not a wiki or a digital garden or a set of links for future reference. All of those things are useful, and they’re all things I’m considering as additions to this site. I’m just not sure yet what combination of these I’ll wind up with.

Wikipedia seems to define a personal wiki as something on a local computer or USB stick, to be carried around.1 But I reject their reality and substitute my own. There are many online personal wikis that are done very well and provide utility to both the site owner and anyone else who wanders into them. At some point, the line between personal and public wiki could get debatable, but it’s pretty easy to categorize a wiki made by an individual, for primarily that individual and anyone else who might care, containing information of interest to that specific individual, as a personal wiki.

I first started thinking about an online personal wiki when I was into Eleventy, and I came across Robb Knight’s personal wiki built in Eleventy and made public on GitHub. I was planning on taking his code and modifying it to suit my needs. But then more pressing projects happened, and then I started getting into Astro, and the Eleventy wiki never happened. However, I like the idea because I do want an always accessible categorized set of links I can rely on, and I like the wiki idea as a container for those links. Links + context, in other words. The result is that now I’m going to need to make my own in Astro, and if you think that sounds like a complaint, you don’t realize how much fun I’m having working with Astro. 😂

The Digital Garden concept is a little harder for me to decide what I want to do with. Digital gardens seem to be more like blogs than wikis in terms of post style, but more like wikis in terms of linking to concepts or being categorized in a certain way. It’s very nebulous. Also it seems like digital gardens are more about reading experience and less about finding information quickly or efficiently. I’ve never seen a digital garden yet where I could find what I thought was the root of a topic or category and dig into it. It’s more like wandering around finding things randomly, which doesn’t really meet my needs for linking to things in an organized manner nor for blogging things that are semi-easy to follow as a narrative.

And let’s be honest, there are only so many hours in the day and I already have put off making a wiki for months, at least. So I think right now I’ll pursue a strategy of making a personal wiki site subdomain as well as slowly adding in some of my posts from the previous incarnation of the blog to this one.

What say ye, dear reader?

Footnotes

  1. Strangely enough for a wiki site that’s dependent on being online… or maybe it’s not so strange at all.

RSS, Astro, and Me – Part 2

Part of the Astro series

As I mentioned in Part 1 of this installment, while trying to modify my site RSS feed to contain the full body of each post in my feed items, I ran into an inconvenient truth about how MDX exposes its file content as a component and how I could not use that component outside of an Astro component. JavaScript just doesn’t know what it is. Support for the MDX Content component has to be built into whatever framework you’re using.

Astro, of course, is built to use MDX and take full advantage of the MDX Content component, so Astro Discord member Chris Adiante proposed I simply use an Astro component to create the RSS (with full access to the MDX content) and then have it write the rss file to the file system. Since my site is Astro SSG (fully static, only changing when I rebuild the site) and not Astro SSR (server-rendered on demand), I can use this technique without any problems.1

By the way, Chris is the creator of the really amazing looking Astro M²DX remark plugins. If you’re using MDX with Astro, you should definitely give these a look!

The Strategy

In order to write an RSS feed using an Astro component, the Astro component has to get called – or to put it another way, it has to be used in a page. It can’t just sit in a folder in src. Also, it should be called once and once only. It can’t be put inside a layout file used by multiple pages because I want it written just once during the build. And finally, it has to have no effect on the rendering of the page that it’s in. Its purpose is to generate the RSS for all site posts and then write that RSS to a file called rss.xml. It has nothing that should be displayed, and it cannot alter the output of the page that hosts it.

To meet these requirements, I decided to use this Astro component in index.astro, the Astro page that gets built into index.html.

The Components

I use two components to create my RSS file, in the manner suggested by Chris Adiante: WriteFile.astro and RssXml.astro. RssXml.astro generates the RSS and WriteFile takes its output and dumps it to disk in the form a file called rss.xml.

RssXml.astro

The way RssXml.astro works is dictated by the fact that in order to render, it needs to directly output some html tags outside a javascript loop. This is because Astro components write html. It’s how they work.

The fact that Astro components write html should not be overlooked because it can also mess with the actual RSS XML generated, a truth that caused me much grief until I learned about Fragments and set:html. Using them in the combination <Fragment set:html="" /> outputs RSS XML that isn’t messed with by Astro trying to ruin the format of XML link elements, for example.

src/components/RssXml.astro
---
import config from "config";
import path from 'path';
import { rfc2822 } from "../components/utilities/DateFormat.js";
const { allPosts } = Astro.props;
const rssHeaderXml = `<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/rss/styles.xsl" type="text/xsl"?>
<rss version="2.0">
<channel>
<title>${config.get("title")}</title>
<description><![CDATA[ ${config.get("description")} ]]></description>
<link>${config.get("url")}</link>`;
const rssFooterXml = ` </channel>
</rss>
`;
---
<Fragment set:html={rssHeaderXml} />
{allPosts.map(post =>
<Fragment set:html={`
<item>
<title>${post.frontmatter.title}</title>
<link>${new URL(
path.join("/", path.basename(post.file, path.extname(post.file))),
config.get("url")
)}</link>
<guid>${new URL(
path.join("/", path.basename(post.file, path.extname(post.file))),
config.get("url")
)}</guid>
<description><![CDATA[ ${post.frontmatter.description}]]></description>
<pubDate>${rfc2822(post.frontmatter.date)}</pubDate>
<content:encoded><![CDATA[`} />
<post.Content/>
<Fragment set:html={`]]></content:encoded>
</item>`} />)}
<Fragment set:html={rssFooterXml} />

This is not a complex component. All it has to do is generate and output XML. It doesn’t even have to get the posts to include, because those are passed in as a prop.

Some points of note:

  • I create a constant for the top of the RSS file above the items which holds all the channel tags, and a constant for the end of the file after the items (which is just the closing channel and RSS tags).
  • Secondly, the items are created in a JavaScript .map function which takes the array of posts and maps each item to create HTML fragments for them. It’s all very straightforward if you’ve looked at an RSS feed before.
  • The most interesting detail to note by far, and indeed the whole reason behind this custom RSS approach, is the <post.Content/> component inside the .map function. For each post being mapped, I have a <Fragment/> wrapping everything before the post.Content object, then I end it, reference the post.Content object, and then create another Fragment object to wrap up the item XML.

It’s important to understand that <post.Content/> is being accessed directly inside the Rss.Xml Astro component, it’s not inside a JavaScript string or any other wrapper.

It has to be directly written in Astro as a direct Astro component item, or it will be meaningless. It’s Astro that understands what to do with this Content component, not JavaScript or any other framework.

WriteFile.astro

If you’re hoping for a giant code listing that requires lots of explanation, WriteFile.astro will make you sad. It’s ridiculously simple:

src/components/WriteFile.astro
---
import fs from "node:fs/promises";
export interface Props {
fileUrl: URL;
}
const { fileUrl } = Astro.props;
if (Astro.slots.has("rss-writer")) {
const html = await Astro.slots.render("rss-writer");
await fs.writeFile(fileUrl, html);
}
---

It’s 100% frontmatter. There is no output. If this has you thinking “but you just told me you have to have output in an Astro component”, that’s only true for Astro components that have to output any text. This component does not have to, it takes the text generated by RssXml.astro and writes it to disk. I understand completely if that explanation doesn’t clarify things much, but if you start playing with Astro components or pages, you’ll find out what happens when you don’t write html tags or include other Astro components in the base output.2

The important point with this one is waiting for that slot to render before performing fs.writeFile. I never would have known to do this, or especially how to do this. This was all Chris Adiante. The reason it’s necessary to do this is because the page needs to be written up to that point for the RssXml.astro component to do its thing and have something for WriteFile.astro to actually write to disk.

The one thing I did differently to his suggestion is to not look and wait for default slot. Using default slot meant everything before RssXml.astro’s output also got written into the file. Not what I want. As a result, I created a named slot in my Base.astro layout template (which index.astro uses) and then target both WriteFile.astro and RssXml.astro to this slot inside of index.astro.

index.astro content section

This is just the content section of index.astro without any frontmatter, just to show you how I incorporated my WriteFile and RssXml Astro components so that they do their thing when the index page is built.

src/pages/index.astro
<Base title={title} , description={description}>
<section aria-label="Blog post list">
{
indexPosts.map((mdxpost) => {
return (
<Post content={mdxpost}>
<mdxpost.Content />
</Post>
);
})
}
<nav id="pager">
{
allPosts.length > pageSize ? (
<div>
<a href="/2">Older Posts</a>
</div>
) : null
}
</nav>
</section>
<WriteFile fileUrl={rssFileUrl} slot="rss-writer">
<RssXml allPosts={allPosts} slot="rss-writer" />
</WriteFile>
</Base>

The above is everything in index.astro except the frontmatter section. The part that writes the RSS file is at the bottom. One thing about named slots that tripped me up is that both components have to name the slot to use explicitly, not just the outer component. Anything you want to wind up in a named slot needs to name that slot.

Base.astro

The final piece of the puzzle is just the named slot at the bottom of Base.astro, my layout template used by index.astro and all my other pages.

src/layouts/Base.astro
---
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import "../styles/sw2.css";
export interface Props {
title: string;
description: string;
url: string;
}
const { title, description } = Astro.props;
---
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link
rel="preload"
href="/fonts/BlinkMacSystemFont-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
<script is:inline src="/scripts/barefoot.min.js"></script>
<script is:inline>
lf = new BareFoot();
lf.init();
</script>
</body>
<slot name="rss-writer" />
</html>

See that innocent looking slot down at almost the very bottom named “rss-writer”? That’s the named slot that allows index.astro to render the WriteFile and RssXml astro components. You might also notice the default <slot/>in between the html <main> tags. That’s where all the content generated by any page that uses Base.astro as its layout gets rendered.

TLDR; and Summary

There’s a lot to digest here. The key takeaways are:

  • Accessing MDX content is done through a Content component, which Astro understands, but JavaScript does not.
  • As a result, trying to access that content for a full-item-body RSS feed requires using Astro components to generate the feed.
  • Astro components only write HTML, not XML or an RSS feed file. Using Astro components to generate XML therefore means writing that to disk yourself.
  • This only works in SSG environments. You would not want to use this with an SSR site because it would write rss.xml over and over again. If I were going to use this on an SSR site, I’d want a page with Astro components that isn’t public facing, which would get called by a node script executed by a cron job or whenever a post is added to the site.

As always, I’m not a brilliant programmer and I’m sure there are better ways of doing everything I did. I also would never have figured out the syntax and layout requirements for this without the help of Chris in the Astro Discord.

Footnotes

  1. Astro currently forces a choice between a fully static site (SSG) or a server-rendered site (SSR). Future support for SSG with SSR routes as needed is on the roadmap (I think).

  2. Hint: Nothing. Nothing is what happens.

RSS, Astro, and Me – Part 1

Part of the Astro series

The first Astro site I put on the web was Siracusa Says, which went live on August 7th. The second Astro site was this site, on August 21st. If you think about how bare bones this site is, and that there’s a 3 week gap there, you might be tempted to think that Astro doesn’t allow for particularly quick development. The truth is, it does, but I also have a day job that was more demanding than normal during that time. In fact, this site was super simple to build. The thing that took me the longest was figuring out a look that I would only have to be 90% ashamed of.

I’m not a designer.

But it’s not all unicorns and fluffy kitties with Astro. Astro is a very new framework and it’s very much a work in progress. One of the late design decisions taken by the development team before Astro 1.0 was released was to stop developing customized markdown with component support, and make markdown just markdown, and use MDX for markdown with component support.

MDX is an interesting animal. If you create an MDX file, the MDX spec will give you access to parts of that file in different ways. For example, the body of the document (in other words, the actual content) is exposed as a component. It’s an object. And being able to access that object depends on whatever framework you’re using supporting MDX and providing that access for you.

Astro does provide this ability to access MDX content as an object. Let’s say you grab all your site’s posts, which happen to be mdx files, using Astro.glob, like this:

let allPosts = await Astro.glob("../content/*.mdx");
allPosts = allPosts.sort(
(a, b) =>
new Date(b.frontmatter.date).valueOf() -
new Date(a.frontmatter.date).valueOf(),
);

You now have an array of posts in the variable allPosts. You can use JavaScript’s map function to deal with them individually, like this:

{
allPosts.map((mdxpost) => {
return (
<Post content={mdxpost}>
<mdxpost.Content />
</Post>
);
});
}

Without worrying too much about the rest of the details here, just understand that <mdxpost.Content /> is a component object that exposes the content from one post. The map contains all the posts, and each mapped mdxpost has a .Content component that holds the content.

If it makes your head hurt and you find it weird, you’re not alone. I guess people coming from JavaScript frameworks like React might be used to things like this - I’m not really sure because I don’t know anything about React. At any rate, this type of JavaScript component is not an unknown thing, it’s just new to me.

Now that you have some of the backstory on how MDX works, let me just say that it created a bit of a problem for me with respect to my RSS feed. The reason for this is that Astro components output HTML. Only HMTL. They can’t output JSON or XML or anything other than HTML. This means the @astrojs/rss package that provides RSS support to Astro doesn’t use Astro components to create the RSS file, it uses JavaScript (most likely TypeScript). It therefore does not support the MDX file Content component object, and it therefore means that creating an RSS feed that Astro way limits me to a summary type feed, without full body content for each item in the feed.

Here’s my rss.xml.js for the Siracusa Says RSS feed as an example:

rss.xml.js
export const get = () =>
rss({
stylesheet: "/rss/styles.xsl",
title: config.get("title"),
description: config.get("description"),
site: config.get("url"),
items: Array.from(episodes)
.reverse()
.map((episode) => ({
title: episode.frontmatter.title,
link: new URL(
path.join(config.get("episodes.path"), episode.frontmatter.slug),
config.get("url"),
),
pubDate: rfc2822(episode.frontmatter.pubDate),
description: episode.frontmatter.description,
customData: `<enclosure url="${config.get("episodes.audioPrefix")}/${
episode.frontmatter.audiofile
}" length="${episode.frontmatter.bytes}" type="audio/mpeg" />`,
})),
});

Initially I thought I could use the customData property of the rss package to stuff my MDX file content into, but there is literally no way to get add the .Content component in a way that this JavaScript understands. The best I can do is see the function that returns it or see [object Object]. Not very useful.

To summarize all the above: using MDX as my post content files and @astrojs/rss to support rss feed creation in Astro resulted in my only being able to provide truncated RSS feed items. In order to solve this, I would have to take the advice of Astro Discord member Chris-Adiante and use an Astro component to render the RSS, allowing access to each posts .Content component, and then writing the rss to the filesystem as an rss.xml file.

That’s exactly what I did. I’ll show you how in Part 2.

Anew

The last time I posted to my site was on February 24th. That’s 6 months between posts.

Far from being dead, the site was just undergoing a metamorphosis that was invisible to you. And to me, very honestly. I’ve been working on other projects, like Friends with Beer and Siracusa Says, so this site was pushed to the back of the queue for its inevitable and much needed refactoring.

When I built this site, I was new to modern static site generators. I used Hugo, which was super popular at the time (and maybe still is) because of its performance and because the web store was one of two wildly divergent camps: single-page web applications, built in React or similar, and completely static sites with little to no interactivity. Hugo is great for fully static websites because it’s FAST and because it’s a very well thought out framework with many of the features people want included by default. But it uses the horrific Go templating language which was apparently created by Yoda and which broke my brain every time I tried to make an even semi-complicated query or logic statement.

Even worse than Go templating language when I built this site with Hugo was my understanding of how to structure it. I didn’t fully grasp how Hugo built pages and directories based on template names and types, and so I came up with a very convoluted scheme in which all of my different categories had their own folders. Not great. And I should never have had so many categories to begin with, which was another failure of my imagination. The end result was a site that was way too complicated for a guy who just wanted to babble about things most people will never read.

I thought I’d build the next iteration with Eleventy. It’s what I used for Friends with Beer, and I learned a lot from that. The beauty of Eleventy is that it’s Node.js based and allows for templates and layouts that use good old Javascript instead of terrible templating languages, although I primarily used Nunjucks for the templating, because that’s what a lot of the best examples I could find at the time used. Also, since you can just import modules that you or other people write, I was able to just write a lot of straight code in modules and use those in my layouts.

But then as I was getting my head wrapped around THAT mode of working, along came a new framework: Astro. On the surface, Astro is a lot like Eleventy. It’s Node.js based, it allows for a lot of Javascript (actually its default is Typescript) modules, and it has a great static site story. But it’s more than just that, and there are a list of things that make it more attactive to me than Eleventy:

  • It doesn’t use templating languages. It used HTML and Javascript and Components.
  • It was built to allow for flexibility in terms of interactivity. Zero Javascript to the client by default, but an Islands Architecture approach allows adding what you need as you need it. An example is my search page, powered a Solidjs component that executes everything on the client in Javascript.
  • It has an SSR story as well, and you can deploy it to several different platforms including hosting your own with a Node adapter.
  • Finally, it’s just very easy to build with. It’s more reminiscient of the old days of Asp.NET or PHP but with modern frameworks and platform functionality. It’s user friendly for both programmers and site visitors, and it remembers what the web actually is and works with those strengths instead of pretending the web is something it’s not.

Astro had a very tumultous beta period, but the developers were purposefully working towards getting a stable 1.0 that could be used and built upon, and I think they’ve succeeded. There are some weirdnesses and omissions that most of us who’ve used other frameworks notice immediately, but mostly there aren’t, and mostly it’s just very easy and fun to work with. Just as importantly, the team and community behind it is overwhelmingly positive.

This site runs on Astro. It makes use of things like @astrojs/image, @astrojs/mdx, @astrojs/rss, data-icon, SolidJs, and a few other tools, as well as the standard use of layouts and components that are the backbone of building an Astro site.

I will absolutely write about Astro and some of the things I’ve had to learn on this site. As always, I’m not a professional programmer and I’m certainly not the quickest or smartest. I do it because I enjoy it, and I think that’s ok. More people should feel good about doing things they enjoy but probably wouldn’t be considered authorities on. Life will be better if we get back to allowing each other that more often again.

There’s also the matter of my ever-evolving computer platform approach, and I have made some decisions in that regard lately. I can’t wait to get started on them, but that will take some money I don’t have just yet, and it will be something I write about quite a lot.

For now… let’s begin anew, friend.