Posts tagged with Colophon

Post about the technologies and design choices behind this website.

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.

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. 🌱