r0b
Homepage

All posts

Host an ics calendar feed with Eleventy

I recently wanted to host an ical feed for some events that I was organising. For the last MozFest, we added a feature to let you subscribe to the events in your MySchedule in your own calendar. People quite liked this feature and I thought it could work nicely in an Eleventy website.

Under the hood a calendar feed is a ical file that is hosted on the web. In a calendar client, you subscribe to that URL and it will periodically fetch that file, parse out the events in there and show them in your calendar.

You could manually create or template that ical file, but that would be pretty fiddly and rife for mistakes, so let's use the ical-generator package instead.

Set up

Let's create a fresh Eleventy project to see how this all works. First we'll need an NPM to install Eleventy and ical-generator.

mkdir eleventy-ical
cd eleventy-ical

npm init -y
npm install @11ty/eleventy ical-generator

All of the code is at examples/eleventy-ical if you want to jump ahead and see it all in one place.

Events collection

In this set up, each event will be a page in an Eleventy collection. In Eleventy you can group pages using tags to later query for them in other pages. We can also tell Eleventy not to render these pages by setting permalink: false in their front-matter. We can do both of these easily by setting it using a directory data file which means there is less to configure in each event page.

So let's create the events/events.json in a new events directory like below:

{
  "tags": ["events"],
  "permalink": false
}

Let's put an event in our new collection. You can name these whatever you want, for my calendar I've numbered them and padded the start so they're nice and alphabetical in my IDE. Each event has a name and location along with the start and end dates. Then the page body can be used for the even description. Here's events/001.md:

---
title: Meeting 001
start: 2023-05-02T15:00:00Z
end: 2023-05-02T16:00:00Z
location: User Study Space
---

This is gonna be the best event ever!

Calendar metadata

To generate a feed, its useful to define some of the metadata so it can also be used within other Eleventy templates. So let's create _data/calendar.json:

{
  "title": "My calendar",
  "description": "Events about all the cool things",
  "organisation": "Rob's calendars",
  "url": "https://feed.r0b.io"
}

This isn't necessary just to generate the feed but it is useful to share these values with other Eleventy templates, like pages that showcase the feed.

Eleventy template

Now we have an event and the metadata, we can finally generate our feed. To do this, we'll use an Eleventy class-based template. This means that we can dynamically generate a page with the exact contents we want.

Here's the feed.11ty.js to create:

const {
  ICalCalendar,
  ICalAlarmType,
  ICalCalendarMethod,
} = require('ical-generator')

module.exports = class FeedTemplate {
  // Setup Eleventy data for this template,
  // namely set the name of the file to be generated
  data() {
    return {
      permalink: 'feed.ics',
    }
  }

  // The render method is called
  render({ calendar, collections }) {
    // Generate a calendar object based on the calendar configuration
    // plus information provided by eleventy
    const cal = new ICalCalendar({
      name: calendar.title,
      description: calendar.description,
      prodId: {
        company: calendar.organisation,
        product: 'Eleventy',
      },
      url: calendar.url + this.page.url,
      method: ICalCalendarMethod.PUBLISH,
    })

    // Loop through of each of our events using the collection
    for (const page of collections.events) {
      // Create a calendar event from each page
      const event = cal.createEvent({
        id: `${calendar.url}/${page.fileSlug}`,
        start: page.data.start,
        end: page.data.end,
        summary: page.data.title,
        description: page.template.frontMatter.content,
        location: page.data.location,
      })

      // Add an alert to the event
      const alarm = new Date(page.data.start)
      alarm.setMinutes(alarm.getMinutes() - 15)
      event.createAlarm({
        type: ICalAlarmType.display,
        trigger: alarm,
      })
    }

    // Generate the ical file and return it for Eleventy
    return cal.toString()
  }
}

There a few bits going on here. The file is exporting a class which is our Eleventy template. Eleventy will create an instance of this class for us and call the methods when it needs to.

Eleventy will first call data() on our class to generate the data for the page, similar to the front-matter in a markdown file. We use this to set the permalink to tell Eleventy what we want our file to be called.

Next Eleventy will call the render(data) method. This takes all the data Eleventy has generated from the cascade and passes it as a parameter for use in JavaScript. Here we destructure the calendar field, generated from the data above, and collections. It's also useful to remember that you have access to the this inside these methods which have extra information, like the page's URL.

Inside the render, it first creates a calendar object and sets up the metadata for the feed.

After setting up the calendar it loops through each page in the events collection. For each page it creates a corresponding event object and adds it to the calendar. One important point about events is the id attribute, this should be unique and persistent so calendar clients know whether to create a new event or update an existing one. To keep this unique it combines the URL of the feed itself and the identifier of the event.

I added a bit of logic here to create an alert on the event 15 minutes before the event starts. This doesn't have to be hardcoded and you could define this on more of a per-page basis if you like.

Finally, the render function generates the ics and returns it for Eleventy to create a file using the cal.toString() method.

Timezones

You can set the timezone on the calendar object or on the events themselves, but it isn't as simple as that. An ical feed needs a VTimezone object which you need a generator for. This can be provided by a library like @touch4it/ical-timezones. There are some good docs in the ical-generator readme for more information.

For my use-case I didn't need this. I used a little Node.js script to generate the pages which creates the date objects for me and automatically writes them with the correct offset in UTC so they're at the right time. By default all dates in the calendar are assumed to be in UTC.

Generating the feed

Now that everything is set up, you should have a directory structure like this:

.
├── _data
│   └── calendar.json
├── events
│   ├── 001.md
│   └── events.json
├── feed.11ty.js
├── node_modules
│   ├── ...
├── package-lock.json
└── package.json

We can run Eleventy and it will generate our ical feed for us! This will create _site/feed.ics which contains the content of our feed, ready to host on the internet.

npx eleventy

Next steps

timezones — As mentioned above, I didn't really get into timezones, so things could definitely be improved here. The ical-generator docs are the best place to start for this.

create a site — You might want to host a little static website alongside your feed. It could simply link to the feed URL with some subscribe instructions or it could pull in event information in a fun dynamic way.

host the site — The website needs to be hosted for people to subscribe to your events!

better event descriptions — This set up dumps the markdown content into the body of the events but markdown won't work in calendar clients.

per-event alerts — You could define an alerts section in an event's front-matter and use that to set different alerts on different events.

generator script — My feed was Tuesday-based so I created a script to create an event on the next occurring Tuesday and fill in the front-matter for me. You could do something similar or even take arguments for how you want to create the events. If you were feeling fancy you could hook up something like decap-cms to edit the events using a web UI.


Hit me up on Mastodon if you liked this, have feedback or just want to know more!