VOOZH about

URL: https://dev.to/sendotltd/building-an-rfc-5545-ical-file-generator-line-folding-escaping-and-all-5fid

⇱ Building an RFC 5545 iCal File Generator — Line Folding, Escaping, and All - DEV Community


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

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.