Add ICS Output to Hugo Blog

Preface

I roughly talked about adding ICS output to Hugo Blog in iCalendar (ICS) 的养成方式#延伸 and had no success at the time. Today I tried another time (probably the forth time in total?) w/ the help of Copilot and fortunately it finally worked.

Now you can subscribe posts ICS or simply click on the calendar icon in the sidebar. Just like RSS, en or CJK is also available for your convenience. Taxonomy is not supported though (you can still use RSS instead).

Procedure

  1. In Hugo site directory, create an ICS template named list.ics under layouts/_default/list.ics
  2. Add ICS output in hugo.toml (or config.toml, if you have not migrated yet)
  3. hugo --gc
  4. Done

Sounds easy, right? Well, it’s not that easy to strip down all those tedious dirty hacks to such simple procedure in the first place.

Structure

Before proceeding, we’d better have a panorama about how the files are structured:

.
├── hugo.toml # or config.toml
├── content
│   └── posts
│       ├── my-first-post.md
│       ├── my-second-post.md
│       └── ...
├── layouts
│   ├── _default
│   │   ├── single.html
│   │   ├── list.html
│   │   ├── ...
│   │   └── list.ics
│   └── ...
├── public
│   ├── posts
│   │   ├── my-first-post
│   │   ├── my-second-post
│   │   ├── ...
│   │   └──── index.ics
│   └── ...
└── static
    └── ...

Template

Content

This template defines basic information of ICS, e.g. version, identifier (PRODID), starting/ending date etc.

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Hugo//Geekademy//Vinfall
CALSCALE:GREGORIAN
METHOD:PUBLISH
{{- range where .Site.Pages "Section" "posts" -}}
{{- if not .IsNode }}
BEGIN:VEVENT
UID:{{ md5 (printf "%s-%s" .Page.Title (.Date.Format "2006-01-02")) }}
ORGANIZER;CN={{ .Page.Params.author }}
DTSTART:{{ .Date.Format "20060102T150405Z" }}
DTSTAMP:{{ .Lastmod.Format "20060102T150405Z" }}
SUMMARY:{{ .Page.Title }}
DESCRIPTION:{{ .Page.Params.description }}\nhttps://blog.vinfall.com{{ .Permalink | absURL }}
CATEGORIES:post_release
END:VEVENT
{{- end -}}
{{ end }}
END:VCALENDAR

Explanation

You can find all possible fields on iCalendar.org or dauntlessly read those RFC files (notably RFC 5545 & RFC 7986). iCalendar.org provides an iCalendar Validator as well so you don’t have to refresh webpages, delete & import ics over and over again like me :)

Anyway, I’ll explain it a bit.

The first thing is, RFC-5545 requires ICS content lines to be delimited by a line break, which is a CRLF sequence (CR character followed by LF character). Although in real world, many programs would happily embrace LF ones, it’s better to align with the spec I guess?

Next, let’s break up (after Valentine’s Day) VEVENT:

{{- range where .Site.Pages "Section" "posts" -}}
{{- if not .IsNode }}
BEGIN:VEVENT
UID:{{ md5 (printf "%s-%s" .Page.Title (.Date.Format "2006-01-02")) }}
...
END:VEVENT
{{- end -}}
{{ end }}

{{- if not .IsNode }} excludes Hugo sections like posts so they would not cause issue. Also, if you happen to know {{- balabala -}} would clean up spaces and wonder why I only write the left part, yes, there is a reason: the output would become END:VEVENTBEGIN:VEVENT otherwise, which is problematic and no program would recognize those VEVENT. The same goes to the last {{ end }}, do NOT clean spaces here.

UID:{{ md5 (printf "%s-%s" .Page.Title (.Date.Format "2006-01-02")) }} generates a md5 sum based on page title and ISO date, as {{ .UniqueID }} or {{ .Page.UniqueID }} does not work for me and iCalendar Validator throws so many errors about missing UID property that I can’t stand it.

I wasted a few minutes figuring out (.Date.Format "2006-01-02") part, omitting the parentheses would throw out an error so don’t do that:

ERROR render of "section" failed: "~/blog/layouts/_default/list.ics:9:44": execute of template failed: template: _default/list.ics:9:44: executing "_default/list.ics" at <.Date.Format>: wrong number of args for Format: want 1 got 0

DTSTART and DTSTAMP are another pair of trouble makers. It’s a bit complicated so just RTFM on Event Component:

DTSTART:{{ .Date.Format "20060102T150405Z" }}
DTSTAMP:{{ .Lastmod.Format "20060102T150405Z" }}

It’s NOT a slip of pen (again) by adding a new line, just an attempt to reduce warnings about Content Lines (again). This time about line length (Line length should not be longer than 75 characters). Never mind, it seems the output should just be one-liner. And I have to hardcode URL as {{ .Site.Params.baseURL }} is also problematic:

DESCRIPTION:{{ .Page.Params.description }}\nhttps://blog.vinfall.com{{ .Permalink | absURL }}

Keep in mind that it’s possible to trigger a parser error in ICSx⁵ when dealing with ORGANIZER according to this discussion comment:

ORGANIZER;CN={{ .Page.Params.author }}:MAILTO:no@thankyou.com

More

You can also put list.ics under layouts/posts/list.ics or wherever you like provided you know what you’re doing or love debugging. Just don’t forget to replace iCalendar subscription URL with your customization later on.

It’s also possible to create layouts/posts/single.ics to have individual ICS. The template of course needs some changes. I just find it fairly useless…

Configuration

So much talk on template, hugo.toml (or whatever file extension you prefer) is more straightforward:

[outputs]
home = ["html", "rss", "ics"]
section = ["html", "rss", "ics"]
taxonomy = ["html", "rss", "ics"]
page = ["html", "ics"]

[outputFormats]
[outputFormats.ICS]
name = "ics"
mediaType = "text/calendar"
baseName = "index"
isPlainText = true
permalinkable = true

Above is a complete config, personally I use the following:

[outputs]
section = ["html", "rss", "ics"]
[outputFormats]
[outputFormats.ICS]
name = "ics"
mediaType = "text/calendar"
baseName = "index"
isPlainText = true
permalinkable = true

If you don’t understand something, just read Custom output formats.

Postscript

Now run hugo serve and head to /posts/index.ics to download the new homebrew ICS. Or simply run hugo --gc and open public/posts/index.ics. Pushing changes to remote to have an ICS link you can subscribe in any calendar apps with ICS support.

Vinfall's Geekademy

Sine īrā et studiō