Building an RFC 5545 iCal File Generator — Line Folding, Escaping, and All
iCal files look simple — just key-value pairs in a text format. But RFC 5545 has particular rules: CRLF line endings (not LF), lines must fold at 75 bytes with a continuation character, text fields need escaping for commas/semicolons/backslashes/newlines, and every event needs a unique UID. Get any of these wrong and Google Calendar silently imports nothing.
iCalendar is the de facto standard for sharing events between calendar apps. Google Calendar, Apple Calendar, Outlook — they all speak it. Generating valid iCal files is more finicky than you'd think.
🔗 Live demo: https://sen.ltd/portfolio/ical-builder/
📦 GitHub: https://github.com/sen-ltd/ical-builder
Features:
- RFC 5545 compliant ICS generation
- Multiple events per calendar
- Recurrence rules (daily, weekly, monthly, yearly)
- Reminders / alarms (15 min, 1 hour, 1 day before)
- All-day event support
- Paste existing ICS to edit
- Live preview as you type
- Japanese / English UI
- Zero dependencies, 38 tests
The ICS format
An iCalendar file is a structured text format with BEGIN/END blocks:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//sen.ltd//ICal Builder//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:abc123@sen.ltd
DTSTAMP:20260413T120000Z
DTSTART:20260413T150000Z
DTEND:20260413T160000Z
SUMMARY:Team Meeting
LOCATION:Zoom
END:VEVENT
END:VCALENDAR
Looks simple. But several subtleties break you:
1. CRLF line endings
Not LF. RFC 5545 §3.1:
Lines of text SHOULD NOT be longer than 75 octets, excluding the line break.
The "line break" is \r\n. A file with just \n technically validates on lenient parsers but fails on strict ones. Always emit \r\n between lines.
2. Line folding at 75 bytes
If a line is longer than 75 bytes, you must fold it by inserting \r\n (CRLF + single space):
SUMMARY:This is a very long summary that exceeds seventy-five octets and th
erefore must be folded with a leading space on the continuation line
The folding happens at byte boundaries (not character boundaries), which matters for multi-byte characters like Japanese — don't split mid-character.
export function foldLine(line, width = 75) {
const result = [];
let i = 0;
while (i < line.length) {
if (i === 0) {
result.push(line.slice(0, width));
i += width;
} else {
result.push(' ' + line.slice(i, i + (width - 1)));
i += (width - 1);
}
}
return result.join('\r\n');
}
Continuation lines count the leading space as part of the 75-byte budget.
3. Text escaping
Certain characters in TEXT-typed fields need escaping:
export function escapeText(str) {
return str
.replace(/\\/g, '\\\\') // backslash first
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
Order matters: escape backslash first, otherwise the backslashes you introduce for the other escapes get double-escaped. Semicolons and commas are separators in some property values, so they must be escaped in free-text. Newlines become literal \n (two characters, backslash + n).
4. DTSTAMP is mandatory
Every VEVENT needs a DTSTAMP (creation time of the iCal record, not the event start). Forgetting this is a silent failure in some parsers:
BEGIN:VEVENT
UID:...
DTSTAMP:20260413T120000Z
DTSTART:20260413T150000Z
...
END:VEVENT
5. UIDs must be globally unique
If you generate multiple events with the same UID, calendar apps treat them as duplicates and only import one. Use a random ID scheme:
export function generateUID() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}@sen.ltd`;
}
The @domain suffix is traditional but not strictly required.
6. Date formats
-
UTC:
20260413T150000Z(trailing Z) -
Local:
20260413T150000(no Z, interpreted in the default timezone) -
All-day:
20260413(just the date, no time)
export function formatDateTime(date, allDay = false) {
const pad = (n) => String(n).padStart(2, '0');
const Y = date.getUTCFullYear();
const M = pad(date.getUTCMonth() + 1);
const D = pad(date.getUTCDate());
if (allDay) return `${Y}${M}${D}`;
const h = pad(date.getUTCHours());
const m = pad(date.getUTCMinutes());
const s = pad(date.getUTCSeconds());
return `${Y}${M}${D}T${h}${m}${s}Z`;
}
Recurrence rules
RRULE is its own mini language:
RRULE:FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE,FR
RRULE:FREQ=MONTHLY;BYMONTHDAY=15
RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25
The builder supports FREQ + COUNT + UNTIL + INTERVAL + BYDAY. More exotic rules (BYSETPOS, etc.) aren't in the UI but can be handled by the parser if present.
Series
This is entry #83 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/ical-builder
- 🌐 Live: https://sen.ltd/portfolio/ical-builder/
- 🏢 Company: https://sen.ltd/

Top comments (0)