Posts from September 2025

All posts from the month September 2025.

Nails Painted With Black Onyx

Left Hand

Black Onyx Little finger on left hand

Black Onyx Ring finger on left hand

Black Onyx Middle finger on left hand

Black Onyx Index finger on left hand

Black Onyx Thumb finger on left hand

Right Hand

Black Onyx Little finger on right hand

Black Onyx Ring finger on right hand

Black Onyx Middle finger on right hand

Black Onyx Index finger on right hand

Black Onyx Thumb finger on right hand

Building a Calendar Interface in Astro

I wanted my website to have a clean, static calendar interface to showcase all my RSVP posts. At first, I considered using FullCalendar, an excellent package, but a bit too heavy for my needs. I wanted something that rendered at build time, without extra client-side scripting. Building a lightweight calendar in Astro with date-fns was surprisingly straightforward.

Screenshot of the September 2025 calendar interface on myles.garden, showing one event on Sunday the 21st.

Screenshot of the calendar interface for this website.

The Code

I am using date-fns to do a lot of the heavy lifting here.

Get the Static Paths

The getStaticPaths generates multiple page routes from a single .astro page component.

import { getCollection } from "astro:content";
import dateFns from "date-fns";

export const getStaticPaths = () => {
  // The events collection is pretty generic, it would look something like
  // this:
  //
  // const eventsCollection = defineCollection({
  //    loader: glob({ pattern: "*.md", base: "./src/content/events" }),
  //    schema: z.object({
  //        name: z.string(),
  //        startsAt: z.date(),
  //        endsAt: z.date().optional(),
  //        isAllDay: z.boolean(),
  //    }),
  // })
  const allEvents = getCollection("events");

  // We want to get the upper and lower bound of the calendar pages without it
  // we would have to have every possible month.
  const eventDates = allEvents
    .flatMap((post) => [post.data.startsAt, post.data.endsAt])
    .filter(Boolean) as Date[];

  const upperBound = dateFns.endOfMonth(dateFns.max(eventDates));
  const lowerBound = dateFns.startOfMonth(dateFns.min(eventDates));

  // We want to capture all the years and their months between the upper and
  // lower bounds.
  const yearsBetween = dateFns
    .eachYearOfInterval({
      start: lowerBound,
      end: upperBound,
    })
    .map((yearStart) => {
      const yearEnd = dateFns.endOfYear(yearStart);
      const months = dateFns.eachMonthOfInterval({
        start: dateFns.isBefore(yearStart, lowerBound) ? lowerBound : yearStart,
        end: dateFns.isAfter(yearEnd, upperBound) ? upperBound : yearEnd,
      });
      return { months };
    });

  // Finally we can build all the pages that will be generated.
  return yearsBetween.flatMap(({ months }) =>
    months.map((monthStart) => {
      const monthEnd = dateFns.endOfMonth(monthStart);

      // Adjust the start and end to the nearest Monday and Sunday for a full
      // week of coverage, this does mean the week will start on a Monday and
      // end on a Sunday.
      const calendarMonthStart = dateFns.isMonday(monthStart)
        ? monthStart
        : dateFns.previousMonday(monthStart);
      const calendarMonthEnd = dateFns.isSunday(monthEnd)
        ? monthEnd
        : dateFns.nextSunday(monthEnd);

      const days = dateFns
        .eachDayOfInterval({
          start: calendarMonthStart,
          end: calendarMonthEnd,
        })
        .map((day) => ({
          day,
          events: allEvents.filter((event) =>
            dateFns.isSameDay(event.data.startsAt, day),
          ),
        }));

      return {
        params: {
          // You'll want to change this to how your filename is, mine is
          // `[...yyyymmm].astro`.
          yyyymm: dateFns.format(monthStart, "yyyy-MM"),
        },
        props: {
          start: calendarMonthStart,
          end: calendarMonthEnd,
          days,
        },
      };
    }),
  );
};

Build the Interface

The above builds three props: start (first day of the calendar), end (last day), and days (an array of days and their events). I use Tailwind CSS for styling this website, I’ll be using their CSS classes.

{
  /**
   * The border attribute doesn't really work with grids, so to get this to
   * work, we are going to add a slight gap between the grid's cells and
   * subtract the margin a bit on the right. This means we'll have a
   * tic-tac-toe border kind of like this:
   *
   *  MON | TUE | WED | THU | FRI | SAT | SUN
   * -----------------------------------------
   *   29 |  31 |  1  |  2  |  3  |  4  |  5
   *                    ...
   *   27 |  28 |  29 |  30 |  31 |  1  |  2
   */
}
<div class:list={["-mr-0.5 grid grid-cols-7 gap-0.5 bg-black"]}>
  {
    ["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map((weekDay) => (
      <h2
        class:list={[...className, "bg-white text-right font-bold uppercase"]}
      >
        {weekDay}
      </h2>
    ))
  }

  {
    days.map(({ day, events }) => (
      <div class:list={["h-32 bg-white"]}>
        <h3 class:list={["px-1 py-0.5 text-right font-semibold"]}>
          {dateFns.format(day, "d")}
        </h3>
        <ul class:list={["space-y-0.5 overflow-y-hidden text-sm"]}>
          {events.map((event) => (
            <li>
              <a
                href={`/events/${event.id}`}
                class:list={[
                  "flex justify-between rounded-sm px-1 py-0.5",
                  "hover:bg-black/5",
                ]}
              >
                <span class:list={["block flex-shrink truncate"]}>
                  {event.data.name}
                </span>
                {!event.data.isAllDay && (
                  <span class:list={["block flex-shrink-0 text-black/75"]}>
                    {dateFns.format(event.data.startsAt, "h:mm a")}
                  </span>
                )}
              </a>
            </li>
          ))}
        </ul>
      </div>
    ))
  }
</div>

Wrap Up

Now my website has a calendar interface to my RSVP posts.

Nails Painted With Big Apple Red

Left Hand

Big Apple Red Little finger on left hand

Big Apple Red Ring finger on left hand

Big Apple Red Middle finger on left hand

No polish Index finger on left hand

Big Apple Red Thumb finger on left hand

Right Hand

No polish Little finger on right hand

Big Apple Red Ring finger on right hand

Big Apple Red Middle finger on right hand

Big Apple Red Index finger on right hand

Big Apple Red Thumb finger on right hand

JSONFeed in Astro with @astrojs/rss

I wanted this website to publish both an RSS feed and a JSONFeed. The catch: I didn’t want to maintain two separate feed pipelines.

Astro already has a handy @astrojs/rss package that outputs RSS feeds. So I thought—why not reuse the same data shape to generate JSONFeed as well?

Turns out, it’s pretty straightforward.

The Code

Here’s how I reused the @astrojs/rss shape to build a JSONFeed endpoint in Astro.

Formatting the Feed

I built a transformer function to take the RSSOptions and convert them to be a valid JSONFeed 1.1 top-level.

import type { APIContext } from "astro";
import { getCollection } from "astro:content";
import type { RSSFeedItem, RSSOptions } from "@astrojs/rss";

export const formatJsonFeed = (rssOptions: RSSOptions) => {
  const { site, title, description } = rssOptions;

  const homePageUrl = site instanceof URL ? site.href : site;

  return {
    version: "https://jsonfeed.org/version/1.1",
    title: title,
    home_page_url: homePageUrl,
    feed_url: new URL("/feed.json", homePageUrl).href,
    description: description,
    items: [],
  };
};

Formatting Feed Items

Next was transforming RSS items to be valid JSONFeed 1.1 items.

export const formatJsonFeedItem = (rssFeedItem: RSSFeedItem) => {
  const { link, content, title, pubDate, description } = rssFeedItem;

  if (!link) {
    throw new Error("RSS feed item must have a link.");
  }

  return {
    id: link,
    url: link,
    title: title,
    summary: description,
    date_published: pubDate?.toISOString(),
    content_html: content || "",
  };
};

Putting It Together

Next, you’ll need to create a Static File Endpoint to serve the JSONFeed. I put mine at /src/pages/feed.json.ts, which servers it at https://example.com/feed.json.

export async function GET(context: APIContext): Promise<Response> {
  const posts = await getCollection("posts");

  const jsonFeedItems = posts.map((post) =>
    formatJsonFeedItem({
      link: url.href,
      title: post.data.name,
      pubDate: post.data.publishedAt,
    }),
  );

  const jsonFeed = formatJsonFeed({
    title: "Website Feed",
    description: "This is a JSONFeed!!!",
    site: context.site,
    items: jsonFeedItems,
  });

  return new Response(JSON.stringify(jsonFeed, null, 2), {
    headers: { "Content-Type": "application/feed+json" },
  });
}

Auto-Discovery of Your New JSONFeed

To make your JSONFeed discoverable by browsers and feed readers, add a <link> tag in your site’s <head>. For example:

<link
  rel="alternate"
  type="application/rss+xml"
  title="RSS Feed"
  href="/rss.xml"
/>
<link
  rel="alternate"
  type="application/feed+json"
  title="JSONFeed"
  href="/feed.json"
/>

This way, clients that support feed auto-discovery will automatically pick up both formats.

Wrap Up

Now my site has two feeds: RSS for the traditionalists and JSONFeed for the modernists. If you’re already using @astrojs/rss, this pattern makes JSONFeed nearly free. Give it a try—and let me know if you’d like me to extend this into Atom or other formats. 🌱