Skip to content

Commit 45aabab

Browse files
committed
calendar: generate aggregated calendar.ics during build
1 parent 0fc88be commit 45aabab

File tree

4 files changed

+426
-2
lines changed

4 files changed

+426
-2
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ The dev server runs at `http://localhost:1111`.
3434
just build
3535
```
3636

37-
This runs `zola build`.
37+
This runs:
38+
- `just build-calendar-ics` (generates `static/calendar.ics`)
39+
- `zola build`
3840

3941
## Content workflow
4042

@@ -72,6 +74,7 @@ just new-event
7274

7375
## Other useful commands
7476

77+
- `just build-calendar-ics` regenerates the top-level site calendar feed at `/calendar.ics`.
7578
- `just check-links` checks legacy Jekyll URL aliases.
7679
- `just edit` opens the most recent event file.
7780
- `just update-feed-template` refreshes `templates/feed.xml` from Zola builtins.

bin/build-calendar-ics.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env python3
2+
"""Build a site-wide ICS file from event front matter."""
3+
4+
from __future__ import annotations
5+
6+
from dataclasses import dataclass
7+
from datetime import UTC, datetime
8+
from pathlib import Path
9+
from zoneinfo import ZoneInfo
10+
import tomllib
11+
12+
13+
ROOT = Path(__file__).resolve().parent.parent
14+
CONTENT_DIR = ROOT / "content"
15+
CONFIG_PATH = ROOT / "config.toml"
16+
OUTPUT_PATH = ROOT / "static" / "calendar.ics"
17+
CAL_TZ = "America/New_York"
18+
19+
20+
VTIMEZONE_BLOCK = [
21+
"BEGIN:VTIMEZONE",
22+
"TZID:America/New_York",
23+
"X-LIC-LOCATION:America/New_York",
24+
"BEGIN:DAYLIGHT",
25+
"TZOFFSETFROM:-0500",
26+
"TZOFFSETTO:-0400",
27+
"TZNAME:EDT",
28+
"DTSTART:19700308T020000",
29+
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU",
30+
"END:DAYLIGHT",
31+
"BEGIN:STANDARD",
32+
"TZOFFSETFROM:-0400",
33+
"TZOFFSETTO:-0500",
34+
"TZNAME:EST",
35+
"DTSTART:19701101T020000",
36+
"RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU",
37+
"END:STANDARD",
38+
"END:VTIMEZONE",
39+
]
40+
41+
42+
@dataclass(frozen=True)
43+
class Event:
44+
slug: str
45+
title: str
46+
start_utc: datetime
47+
end_utc: datetime
48+
location: str
49+
description: str
50+
url: str
51+
52+
53+
def parse_front_matter(page_path: Path) -> dict:
54+
text = page_path.read_text(encoding="utf-8")
55+
if not text.startswith("+++"):
56+
return {}
57+
58+
end = text.find("\n+++", 3)
59+
if end == -1:
60+
return {}
61+
62+
toml_blob = text[3 : end + 1].strip()
63+
if not toml_blob:
64+
return {}
65+
66+
return tomllib.loads(toml_blob)
67+
68+
69+
def parse_utc(timestamp: str) -> datetime:
70+
# Content stores UTC timestamps with trailing Z.
71+
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).astimezone(UTC)
72+
73+
74+
def ical_escape(value: str) -> str:
75+
return (
76+
value.replace("\\", "\\\\")
77+
.replace("\n", "\\n")
78+
.replace(",", "\\,")
79+
.replace(";", "\\;")
80+
)
81+
82+
83+
def fold_ical_line(line: str, max_octets: int = 73) -> list[str]:
84+
# RFC 5545 line folding: continuation lines start with one space.
85+
parts: list[str] = []
86+
current = ""
87+
current_octets = 0
88+
89+
for char in line:
90+
octets = len(char.encode("utf-8"))
91+
if current and current_octets + octets > max_octets:
92+
parts.append(current)
93+
current = " " + char
94+
current_octets = 1 + octets
95+
else:
96+
current += char
97+
current_octets += octets
98+
99+
if current:
100+
parts.append(current)
101+
return parts
102+
103+
104+
def load_base_url() -> str:
105+
cfg = tomllib.loads(CONFIG_PATH.read_text(encoding="utf-8"))
106+
return cfg.get("base_url", "https://trianglebitdevs.org").rstrip("/")
107+
108+
109+
def collect_events() -> list[Event]:
110+
base_url = load_base_url()
111+
out: list[Event] = []
112+
113+
for page in sorted(CONTENT_DIR.glob("*.md")):
114+
fm = parse_front_matter(page)
115+
extra = fm.get("extra")
116+
if not isinstance(extra, dict) or not extra.get("add_to_calendar"):
117+
continue
118+
119+
start = extra.get("start")
120+
end = extra.get("end")
121+
if not isinstance(start, str) or not isinstance(end, str):
122+
continue
123+
124+
title = fm.get("title", page.stem)
125+
if not isinstance(title, str):
126+
title = page.stem
127+
128+
location = extra.get("location", "")
129+
description = extra.get("description", "")
130+
if not isinstance(location, str):
131+
location = ""
132+
if not isinstance(description, str):
133+
description = ""
134+
135+
slug = page.stem
136+
out.append(
137+
Event(
138+
slug=slug,
139+
title=title,
140+
start_utc=parse_utc(start),
141+
end_utc=parse_utc(end),
142+
location=location,
143+
description=description,
144+
url=f"{base_url}/{slug}/",
145+
)
146+
)
147+
148+
out.sort(key=lambda ev: (ev.start_utc, ev.slug))
149+
return out
150+
151+
152+
def build_ics(events: list[Event]) -> str:
153+
tz = ZoneInfo(CAL_TZ)
154+
lines = [
155+
"BEGIN:VCALENDAR",
156+
"VERSION:2.0",
157+
"PRODID:-//Triangle BitDevs//Calendar//EN",
158+
"CALSCALE:GREGORIAN",
159+
"X-WR-CALNAME:Triangle BitDevs Events",
160+
f"X-WR-TIMEZONE:{CAL_TZ}",
161+
*VTIMEZONE_BLOCK,
162+
]
163+
164+
for event in events:
165+
start_local = event.start_utc.astimezone(tz).strftime("%Y%m%dT%H%M%S")
166+
end_local = event.end_utc.astimezone(tz).strftime("%Y%m%dT%H%M%S")
167+
stamp = event.start_utc.strftime("%Y%m%dT%H%M%SZ")
168+
uid = f"{event.slug}-{stamp.lower()}@trianglebitdevs.org"
169+
170+
event_lines = [
171+
"BEGIN:VEVENT",
172+
f"UID:{uid}",
173+
f"DTSTAMP:{stamp}",
174+
f"DTSTART;TZID={CAL_TZ}:{start_local}",
175+
f"DTEND;TZID={CAL_TZ}:{end_local}",
176+
f"SUMMARY:{ical_escape(event.title)}",
177+
f"LOCATION:{ical_escape(event.location)}",
178+
f"DESCRIPTION:{ical_escape(event.description)}",
179+
f"URL:{ical_escape(event.url)}",
180+
"END:VEVENT",
181+
]
182+
for line in event_lines:
183+
lines.extend(fold_ical_line(line))
184+
185+
lines.append("END:VCALENDAR")
186+
return "\r\n".join(lines) + "\r\n"
187+
188+
189+
def main() -> None:
190+
events = collect_events()
191+
ics = build_ics(events)
192+
OUTPUT_PATH.write_text(ics, encoding="utf-8")
193+
print(f"Wrote {OUTPUT_PATH} ({len(events)} events)")
194+
195+
196+
if __name__ == "__main__":
197+
main()

justfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ new-event:
1919
serve:
2020
zola serve --open --drafts
2121

22+
# Build site-wide ICS feed at static/calendar.ics
23+
build-calendar-ics:
24+
./bin/build-calendar-ics.py
25+
2226
# Open browser to local site
2327
open:
2428
open http://localhost:1111
2529

2630
# Build for production
27-
build:
31+
build: build-calendar-ics
2832
zola build
2933

3034
# Check that old Jekyll URLs still work

0 commit comments

Comments
 (0)