Skip to content

Commit e92e591

Browse files
committed
Validate RFC 5545 calendar feed
1 parent e18fb9b commit e92e591

4 files changed

Lines changed: 130 additions & 1 deletion

File tree

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,6 @@ jobs:
9696
--gc \
9797
--minify \
9898
--destination public
99+
100+
- name: Validate iCalendar feed
101+
run: node scripts/validate-calendar.mjs public/calendar/calendar.ics

hugo.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ params:
146146
branch: "main"
147147

148148
ical:
149-
timezone: UTC
149+
timezone: Etc/UTC
150150

151151
module:
152152
imports:

layouts/list.calendar.ics

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{{- $calendar := "BEGIN:VCALENDAR\n" -}}
2+
{{- $calendar = printf "%s%s" $calendar (partial "header.ics" .) -}}
3+
{{- $timezones := slice -}}
4+
{{- range .Pages -}}
5+
{{- if .Params.startDate -}}
6+
{{- $timezone := partial "ical/get_timezone.ics" . -}}
7+
{{- if not (in $timezones $timezone) -}}
8+
{{- $timezones = $timezones | append $timezone -}}
9+
{{- end -}}
10+
{{- end -}}
11+
{{- end -}}
12+
{{- range $timezones -}}
13+
{{- $calendar = printf "%s%s" $calendar (partial "timezone.ics" .) -}}
14+
{{- end -}}
15+
{{- range .Pages -}}
16+
{{- if .Params.startDate -}}
17+
{{- $calendar = printf "%s%s" $calendar (partial "event.ics" .) -}}
18+
{{- end -}}
19+
{{- end -}}
20+
{{- $calendar = printf "%sEND:VCALENDAR\n" $calendar -}}
21+
{{- replace $calendar "\n" "\r\n" -}}

scripts/validate-calendar.mjs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { readFile } from 'node:fs/promises';
2+
3+
const input = process.argv[2];
4+
5+
if (!input) {
6+
throw new Error('Usage: node scripts/validate-calendar.mjs <calendar.ics|URL>');
7+
}
8+
9+
const calendar = new URL(input, 'file:').protocol === 'file:'
10+
? await readFile(input, 'utf8')
11+
: await (await fetch(input)).text();
12+
13+
const errors = [];
14+
const fail = (message) => errors.push(message);
15+
16+
if (/(^|[^\r])\n/.test(calendar)) {
17+
fail('Content lines must use CRLF line endings.');
18+
}
19+
20+
const lines = calendar.replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter(Boolean);
21+
const components = [];
22+
const events = [];
23+
const timezones = new Set();
24+
const timezoneReferences = new Set();
25+
let event;
26+
27+
for (const line of lines) {
28+
if (line.startsWith('BEGIN:')) {
29+
const component = line.slice('BEGIN:'.length);
30+
components.push(component);
31+
if (component === 'VEVENT') {
32+
event = new Map();
33+
events.push(event);
34+
}
35+
continue;
36+
}
37+
38+
if (line.startsWith('END:')) {
39+
const component = line.slice('END:'.length);
40+
if (components.pop() !== component) {
41+
fail(`Mismatched component terminator: ${line}`);
42+
}
43+
if (component === 'VEVENT') {
44+
event = undefined;
45+
}
46+
continue;
47+
}
48+
49+
const separator = line.indexOf(':');
50+
if (separator < 1) {
51+
fail(`Malformed content line: ${line}`);
52+
continue;
53+
}
54+
55+
const declaration = line.slice(0, separator);
56+
const [name, ...parameters] = declaration.split(';');
57+
const value = line.slice(separator + 1);
58+
59+
for (const parameter of parameters) {
60+
if (parameter.startsWith('TZID=')) {
61+
timezoneReferences.add(parameter.slice('TZID='.length));
62+
}
63+
}
64+
65+
if (components.at(-1) === 'VTIMEZONE' && name === 'TZID') {
66+
timezones.add(value);
67+
}
68+
69+
if (event) {
70+
event.set(name, [...(event.get(name) ?? []), value]);
71+
}
72+
}
73+
74+
if (components.length) {
75+
fail(`Unclosed component: ${components.at(-1)}`);
76+
}
77+
78+
if (lines[0] !== 'BEGIN:VCALENDAR' || lines.at(-1) !== 'END:VCALENDAR') {
79+
fail('Calendar must be wrapped in BEGIN:VCALENDAR and END:VCALENDAR.');
80+
}
81+
82+
for (const [index, properties] of events.entries()) {
83+
for (const property of ['UID', 'DTSTAMP', 'DTSTART']) {
84+
if (properties.get(property)?.length !== 1) {
85+
fail(`VEVENT ${index + 1} must contain exactly one ${property}.`);
86+
}
87+
}
88+
89+
const uid = properties.get('UID')?.[0];
90+
if (uid && events.some((other) => other !== properties && other.get('UID')?.[0] === uid)) {
91+
fail(`VEVENT ${index + 1} reuses UID ${uid}.`);
92+
}
93+
}
94+
95+
for (const timezone of timezoneReferences) {
96+
if (!timezones.has(timezone)) {
97+
fail(`TZID=${timezone} has no matching VTIMEZONE component.`);
98+
}
99+
}
100+
101+
if (errors.length) {
102+
throw new Error(`Invalid RFC 5545 calendar:\n- ${errors.join('\n- ')}`);
103+
}
104+
105+
console.log(`Validated ${events.length} VEVENT components.`);

0 commit comments

Comments
 (0)