DEV Community

SEN LLC
SEN LLC

Posted on

Building an RFC 5545 iCal File Generator — Line Folding, Escaping, and All

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

Screenshot

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`;
}
Enter fullscreen mode Exit fullscreen mode

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`;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)