@nomideusz/svelte-calendar 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -585,6 +585,7 @@ The calendar reads its events through an adapter. Which one:
585
585
  | `createMemoryAdapter(events)` | The events are already in the page (a fixed list, your own state). Writes stay in memory. See [Quick Start](#quick-start). |
586
586
  | [`createRecurringAdapter(rules)`](#recurring-schedules) | The schedule repeats by rule: class timetables, office hours. Read-only. |
587
587
  | [`createRestAdapter(options)`](#rest-adapter) | The events live behind your own HTTP API, which also stores creates and moves. |
588
+ | [`createIcsAdapter(source)`](#icalendar-ics) | The events come from Google Calendar, Apple Calendar, Outlook or any other calendar app: an imported `.ics` file or a subscription URL. Read-only. |
588
589
  | [`createJmapAdapter(client, options)`](#jmap-adapter) | The events are in a JMAP Calendars server (Fastmail, Stalwart, Cyrus). Read-only. |
589
590
  | [`createMappedAdapter(records, options)`](#mapped-adapter) | You have records from another system with their own field names: map the fields instead of writing an adapter. |
590
591
  | [`createCompositeAdapter(adapters)`](#composite-adapter) | One calendar shows several sources, e.g. a recurring timetable plus one-off events. |
@@ -640,6 +641,67 @@ const adapter = createJmapAdapter(client, {
640
641
 
641
642
  Read-only. Expands recurrences server-side and handles all-day events, ISO-duration ends and per-calendar colors (pass `calendars: [{ id, name, color }]`, or a function returning them).
642
643
 
644
+ ### iCalendar (.ics)
645
+
646
+ Every calendar app speaks iCalendar: Google Calendar, Apple Calendar / iCloud, Outlook / Microsoft 365, Thunderbird, Proton, Fastmail, Nextcloud. One adapter reads all of them, from a file or a feed:
647
+
648
+ ```svelte
649
+ <script>
650
+ import { Calendar, createIcsAdapter } from '@nomideusz/svelte-calendar';
651
+
652
+ let adapter = $state(createIcsAdapter(''));
653
+ async function importFile(e) {
654
+ const file = e.currentTarget.files?.[0];
655
+ if (file) adapter = createIcsAdapter(await file.text());
656
+ }
657
+ </script>
658
+
659
+ <input type="file" accept=".ics,.ical,.ifb,text/calendar" onchange={importFile} />
660
+ <Calendar {adapter} />
661
+ ```
662
+
663
+ A subscription URL loads on the first fetch (`webcal://` reads as `https://`); `refreshInterval` refetches once the copy is older than that many ms:
664
+
665
+ ```ts
666
+ // Google: Settings → your calendar → "Secret address in iCal format"
667
+ // Outlook: Settings → Calendar → Shared calendars → Publish → ICS link
668
+ // iCloud: share the calendar as "Public Calendar" → webcal:// link
669
+ const adapter = createIcsAdapter({ url: '/api/calendar-feed', refreshInterval: 15 * 60_000 });
670
+ ```
671
+
672
+ Google, Outlook and iCloud send no CORS headers, so a browser can't fetch their feeds directly: serve them through a route of your own (the feed URL is a secret, so keep it on the server anyway):
673
+
674
+ ```ts
675
+ // src/routes/api/calendar-feed/+server.ts
676
+ export const GET = async ({ fetch }) => new Response(await (await fetch(FEED_URL)).text(), {
677
+ headers: { 'content-type': 'text/calendar; charset=utf-8' },
678
+ });
679
+ ```
680
+
681
+ - Recurrence is expanded per viewed range: `RRULE` (daily, weekly, monthly, yearly; `INTERVAL`, `COUNT`, `UNTIL`, `BYDAY` with ordinals like `-1FR`, `BYMONTHDAY`, `BYMONTH`, `BYSETPOS`, `WKST`), `RDATE`, `EXDATE`, and moved or edited occurrences (`RECURRENCE-ID`).
682
+ - Times keep their zone: UTC, IANA `TZID`s, and the `VTIMEZONE` blocks Outlook writes under Windows zone names. Floating times read in the calendar's `X-WR-TIMEZONE`, else the browser's. All-day dates are local midnights, end exclusive.
683
+ - `SUMMARY` → `title`, `LOCATION` → `location`, `STATUS` → `status`, the first of `CATEGORIES` → `category`, `COLOR` → `color`, `UID` → `externalId`, `DESCRIPTION` / `URL` → `data.description` / `data.url`. Events without a colour take `options.color`, else the calendar's own (`X-APPLE-CALENDAR-COLOR`).
684
+ - Read-only: each occurrence carries `data.readOnly`, and a write throws `CalendarReadOnlyError`, so in a composite a drag reaches `oneventmove`.
685
+ - The source may also be a function returning the text: `createIcsAdapter(() => loadIt())`.
686
+
687
+ To use the events outside an adapter (copy an import into your own store), parse once and expand any range:
688
+
689
+ ```ts
690
+ import { parseIcs } from '@nomideusz/svelte-calendar';
691
+
692
+ const cal = parseIcs(text); // { name, color, events(range) }
693
+ const imported = cal.events({ start, end });
694
+ ```
695
+
696
+ **Export.** `toIcs(events, { name })` writes an `.ics` file that Google, Apple and Outlook import, or subscribe to when you serve it from a URL. For a single event, `googleCalendarUrl(event)` and `outlookCalendarUrl(event, { work })` (personal outlook.live.com, or Microsoft 365) open the app's "add event" page:
697
+
698
+ ```ts
699
+ import { toIcs, googleCalendarUrl, outlookCalendarUrl } from '@nomideusz/svelte-calendar';
700
+
701
+ const file = new Blob([toIcs(events, { name: 'Studio' })], { type: 'text/calendar' });
702
+ const href = URL.createObjectURL(file); // <a {href} download="studio.ics">
703
+ ```
704
+
643
705
  ### Mapped Adapter
644
706
 
645
707
  Wrap any static array of external records (class schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping (or a `mapEvent` function for full control) and the adapter handles parsing, colors, tags and status:
@@ -0,0 +1,60 @@
1
+ import type { TimelineEvent } from '../core/types.js';
2
+ import type { CalendarAdapter, DateRange } from './types.js';
3
+ export interface IcsCalendar {
4
+ /** The calendar's name (X-WR-CALNAME / NAME), when the file has one */
5
+ name?: string;
6
+ /** The calendar's colour (X-APPLE-CALENDAR-COLOR / COLOR), when the file has one */
7
+ color?: string;
8
+ /** Every event overlapping `range`, recurrences expanded */
9
+ events(range: DateRange): TimelineEvent[];
10
+ }
11
+ /**
12
+ * Parse an iCalendar text (.ics, .ical, .ifb; a .vcs reads as far as its
13
+ * events go). Recurring events are expanded per range by `events(range)`.
14
+ * A malformed line or event is skipped, never thrown.
15
+ */
16
+ export declare function parseIcs(input: string): IcsCalendar;
17
+ export type IcsSource = string | {
18
+ /** An https:// or webcal:// feed URL (Google's "secret address in iCal format", Outlook's published ICS, an iCloud public calendar) */
19
+ url: string;
20
+ /** Your own fetch — e.g. one that adds headers or goes through your server */
21
+ fetch?: typeof fetch;
22
+ /** Refetch on the next load once the copy is older than this many ms (default: never) */
23
+ refreshInterval?: number;
24
+ } | (() => Promise<string>);
25
+ export interface IcsAdapterOptions {
26
+ /** Colour for events that name none (default: the calendar's own colour, else ink) */
27
+ color?: string;
28
+ }
29
+ /**
30
+ * A read-only CalendarAdapter over iCalendar data: a file's text, a feed URL,
31
+ * or a function that loads the text. Each occurrence carries `data.readOnly`,
32
+ * and a write throws CalendarReadOnlyError, so a composite hands a drag to
33
+ * `oneventmove`.
34
+ *
35
+ * Feeds from Google, Outlook and iCloud send no CORS headers: in a browser,
36
+ * fetch them through your own server route.
37
+ */
38
+ export declare function createIcsAdapter(source: IcsSource, options?: IcsAdapterOptions): CalendarAdapter;
39
+ export interface ToIcsOptions {
40
+ /** Calendar name (X-WR-CALNAME), shown by Google, Apple and Outlook */
41
+ name?: string;
42
+ /** PRODID (default: -//nomideusz//svelte-calendar//EN) */
43
+ prodId?: string;
44
+ }
45
+ /**
46
+ * Write events as an iCalendar file that Google Calendar, Apple Calendar and
47
+ * Outlook import (or subscribe to, served from a URL). Times go out in UTC;
48
+ * all-day events as dates. UID is `externalId ?? id`. A string
49
+ * `data.description` / `data.url` becomes DESCRIPTION / URL.
50
+ */
51
+ export declare function toIcs(events: TimelineEvent[], options?: ToIcsOptions): string;
52
+ /** An "add to Google Calendar" link for one event. */
53
+ export declare function googleCalendarUrl(ev: TimelineEvent): string;
54
+ /**
55
+ * An "add to Outlook" link for one event: outlook.live.com (personal), or
56
+ * `work: true` for outlook.office.com (Microsoft 365).
57
+ */
58
+ export declare function outlookCalendarUrl(ev: TimelineEvent, options?: {
59
+ work?: boolean;
60
+ }): string;
@@ -0,0 +1,682 @@
1
+ /**
2
+ * iCalendar (.ics, RFC 5545) — read and write the format every calendar app
3
+ * speaks: Google Calendar, Apple Calendar / iCloud, Outlook / Microsoft 365,
4
+ * Thunderbird, Proton, Fastmail, Nextcloud. A file import, a subscription URL
5
+ * ("secret address in iCal format", "publish calendar", webcal://) and an
6
+ * export are all this one format.
7
+ *
8
+ * Usage:
9
+ * import { createIcsAdapter, parseIcs, toIcs } from '@nomideusz/svelte-calendar';
10
+ *
11
+ * const adapter = createIcsAdapter(await file.text()); // a dropped .ics file
12
+ * const feed = createIcsAdapter({ url: '/api/google-feed' }); // a subscription
13
+ * const download = toIcs(events, { name: 'Studio' }); // back out
14
+ *
15
+ * Recurrence: RRULE (DAILY, WEEKLY, MONTHLY, YEARLY with INTERVAL, COUNT,
16
+ * UNTIL, BYDAY incl. ordinals, BYMONTHDAY, BYMONTH, BYSETPOS, WKST), RDATE,
17
+ * EXDATE and RECURRENCE-ID overrides. Times: UTC, floating, IANA TZIDs and
18
+ * the VTIMEZONE blocks Outlook writes under Windows zone names.
19
+ */
20
+ import { getTimezoneOffset } from 'date-fns-tz';
21
+ import { CalendarReadOnlyError, EventNotFoundError } from './errors.js';
22
+ /** One content line: NAME;PARAM=a;PARAM="b:c":value */
23
+ function parseLine(line) {
24
+ let i = 0;
25
+ while (i < line.length && line[i] !== ';' && line[i] !== ':')
26
+ i++;
27
+ const name = line.slice(0, i).toUpperCase();
28
+ const params = {};
29
+ while (line[i] === ';') {
30
+ const eq = line.indexOf('=', i);
31
+ if (eq < 0)
32
+ return null;
33
+ const key = line.slice(i + 1, eq).toUpperCase();
34
+ let val = '';
35
+ let quoted = false;
36
+ for (i = eq + 1; i < line.length; i++) {
37
+ const ch = line[i];
38
+ if (ch === '"')
39
+ quoted = !quoted;
40
+ else if (!quoted && (ch === ';' || ch === ':'))
41
+ break;
42
+ else
43
+ val += ch;
44
+ }
45
+ params[key] = val;
46
+ }
47
+ if (line[i] !== ':')
48
+ return null;
49
+ return { name, params, value: line.slice(i + 1) };
50
+ }
51
+ function parseComponents(text) {
52
+ const root = { name: 'ROOT', props: [], children: [] };
53
+ const stack = [root];
54
+ // Unfold: a line starting with a space or tab continues the previous one.
55
+ const lines = text.replace(/\r\n?/g, '\n').replace(/\n[ \t]/g, '').split('\n');
56
+ for (const line of lines) {
57
+ if (!line.trim())
58
+ continue;
59
+ const prop = parseLine(line);
60
+ if (!prop)
61
+ continue;
62
+ const top = stack[stack.length - 1];
63
+ if (prop.name === 'BEGIN') {
64
+ const child = { name: prop.value.trim().toUpperCase(), props: [], children: [] };
65
+ top.children.push(child);
66
+ stack.push(child);
67
+ }
68
+ else if (prop.name === 'END') {
69
+ if (stack.length > 1)
70
+ stack.pop();
71
+ }
72
+ else {
73
+ top.props.push(prop);
74
+ }
75
+ }
76
+ return root;
77
+ }
78
+ const prop = (c, name) => c.props.find((p) => p.name === name);
79
+ const props = (c, name) => c.props.filter((p) => p.name === name);
80
+ function unescapeText(v) {
81
+ return v.replace(/\\([\\;,nN])/g, (_, ch) => (ch === 'n' || ch === 'N' ? '\n' : ch));
82
+ }
83
+ const text = (c, name) => {
84
+ const p = prop(c, name);
85
+ return p ? unescapeText(p.value) : undefined;
86
+ };
87
+ // ── Time ────────────────────────────────────────────────
88
+ // A "wall" is a wall-clock time stored as UTC milliseconds: its UTC getters
89
+ // read the local fields. Day math on walls is plain DAY steps, DST-free; a
90
+ // wall only becomes an instant through its zone.
91
+ const DAY = 86_400_000;
92
+ const UTC_ZONE = (wall) => wall;
93
+ /** A floating time: the browser's own wall clock. */
94
+ const LOCAL_ZONE = (wall) => {
95
+ const d = new Date(wall);
96
+ return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()).getTime();
97
+ };
98
+ function ianaZone(tz) {
99
+ if (Number.isNaN(getTimezoneOffset(tz, new Date(0))))
100
+ return undefined;
101
+ return (wall) => {
102
+ // Two passes: the offset at the guessed instant settles DST edges.
103
+ const guess = wall - getTimezoneOffset(tz, new Date(wall));
104
+ return wall - getTimezoneOffset(tz, new Date(guess));
105
+ };
106
+ }
107
+ /** "20261026" or "20261026T090000" (Z dropped) → wall */
108
+ function parseWall(v) {
109
+ const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?/.exec(v.trim());
110
+ if (!m)
111
+ return undefined;
112
+ return Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0));
113
+ }
114
+ /** "-PT15M", "P1DT2H", "P1W" → ms */
115
+ function parseDuration(v) {
116
+ const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(v.trim());
117
+ if (!m)
118
+ return undefined;
119
+ const ms = (+(m[2] ?? 0) * 7 + +(m[3] ?? 0)) * DAY + (+(m[4] ?? 0) * 3600 + +(m[5] ?? 0) * 60 + +(m[6] ?? 0)) * 1000;
120
+ return m[1] === '-' ? -ms : ms;
121
+ }
122
+ // ── Recurrence ──────────────────────────────────────────
123
+ const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
124
+ function parseRule(v, zoneOf) {
125
+ const parts = {};
126
+ for (const kv of v.split(';')) {
127
+ const [k, val] = kv.split('=');
128
+ if (k && val)
129
+ parts[k.toUpperCase()] = val.toUpperCase();
130
+ }
131
+ const freq = parts.FREQ;
132
+ // ponytail: HOURLY/MINUTELY/SECONDLY and BYHOUR/BYMINUTE/BYYEARDAY/BYWEEKNO are
133
+ // not expanded (calendar apps don't write them); such an event shows its DTSTART only.
134
+ if (freq !== 'DAILY' && freq !== 'WEEKLY' && freq !== 'MONTHLY' && freq !== 'YEARLY')
135
+ return undefined;
136
+ const nums = (s) => s?.split(',').map(Number).filter((n) => Number.isInteger(n) && n !== 0);
137
+ let until;
138
+ if (parts.UNTIL) {
139
+ const wall = parseWall(parts.UNTIL);
140
+ if (wall != null)
141
+ until = { wall, date: !parts.UNTIL.includes('T'), zone: parts.UNTIL.endsWith('Z') ? UTC_ZONE : zoneOf() };
142
+ }
143
+ return {
144
+ freq,
145
+ interval: Math.max(1, Number(parts.INTERVAL) || 1),
146
+ count: parts.COUNT ? Number(parts.COUNT) : undefined,
147
+ until,
148
+ byDay: parts.BYDAY?.split(',').flatMap((s) => {
149
+ const m = /^([+-]?\d+)?([A-Z]{2})$/.exec(s);
150
+ const wd = m ? WEEKDAYS.indexOf(m[2]) : -1;
151
+ return m && wd >= 0 ? [{ n: Number(m[1] ?? 0), wd }] : [];
152
+ }),
153
+ byMonthDay: nums(parts.BYMONTHDAY),
154
+ byMonth: nums(parts.BYMONTH)?.map((m) => m - 1),
155
+ bySetPos: nums(parts.BYSETPOS),
156
+ wkst: Math.max(0, WEEKDAYS.indexOf(parts.WKST ?? 'MO')),
157
+ };
158
+ }
159
+ const daysInMonth = (y, m) => new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
160
+ const weekday = (wall) => new Date(wall).getUTCDay();
161
+ /** Day offsets in a span of `len` days from `start` matching BYDAY (ordinals count within the span). */
162
+ function pickWeekdays(start, len, byDay) {
163
+ const out = [];
164
+ for (const { n, wd } of byDay) {
165
+ const all = [];
166
+ for (let d = (wd - weekday(start) + 7) % 7; d < len; d += 7)
167
+ all.push(d);
168
+ if (!n)
169
+ out.push(...all);
170
+ else {
171
+ const pick = n > 0 ? all[n - 1] : all[all.length + n];
172
+ if (pick != null)
173
+ out.push(pick);
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+ /** Days (walls at midnight) of month y/m the rule selects; `dtDay` when it names none. */
179
+ function monthDays(y, m, r, dtDay) {
180
+ const len = daysInMonth(y, m);
181
+ const start = Date.UTC(y, m, 1);
182
+ let days;
183
+ if (r.byMonthDay?.length) {
184
+ days = r.byMonthDay.map((d) => (d < 0 ? len + d + 1 : d)).filter((d) => d >= 1 && d <= len).map((d) => d - 1);
185
+ }
186
+ if (r.byDay?.length) {
187
+ const picked = pickWeekdays(start, len, r.byDay);
188
+ days = days ? days.filter((d) => picked.includes(d)) : picked;
189
+ }
190
+ // RFC 5545: a date that doesn't exist (31 Feb) is skipped, not clamped.
191
+ days ??= dtDay <= len ? [dtDay - 1] : [];
192
+ return days.map((d) => start + d * DAY);
193
+ }
194
+ /**
195
+ * Occurrence walls of a rule, in order, from DTSTART (always the first) until
196
+ * the rule ends or a period starts after `to`. `from` lets a rule without
197
+ * COUNT skip the periods that end before it.
198
+ */
199
+ function* expandRule(dt, r, from, to) {
200
+ const tod = ((dt % DAY) + DAY) % DAY;
201
+ const dtDate = new Date(dt - tod);
202
+ const dtY = dtDate.getUTCFullYear();
203
+ const dtM = dtDate.getUTCMonth();
204
+ const dtD = dtDate.getUTCDate();
205
+ let emitted = 0;
206
+ const limit = r.count ?? Infinity;
207
+ yield dt;
208
+ if (++emitted >= limit)
209
+ return;
210
+ const inMonth = (w) => !r.byMonth?.length || r.byMonth.includes(new Date(w).getUTCMonth());
211
+ for (let p = 0;; p += r.interval) {
212
+ let periodStart;
213
+ let days;
214
+ switch (r.freq) {
215
+ case 'DAILY': {
216
+ periodStart = dt - tod + p * DAY;
217
+ if (r.count == null && periodStart < from - DAY) {
218
+ p += Math.floor((from - DAY - periodStart) / DAY / r.interval) * r.interval;
219
+ periodStart = dt - tod + p * DAY;
220
+ }
221
+ const d = new Date(periodStart);
222
+ const ok = inMonth(periodStart) &&
223
+ (!r.byDay?.length || r.byDay.some((b) => b.wd === d.getUTCDay())) &&
224
+ (!r.byMonthDay?.length ||
225
+ r.byMonthDay.some((md) => (md < 0 ? daysInMonth(d.getUTCFullYear(), d.getUTCMonth()) + md + 1 : md) === d.getUTCDate()));
226
+ days = ok ? [periodStart] : [];
227
+ break;
228
+ }
229
+ case 'WEEKLY': {
230
+ const week0 = dt - tod - ((weekday(dt) - r.wkst + 7) % 7) * DAY;
231
+ periodStart = week0 + p * 7 * DAY;
232
+ if (r.count == null && periodStart < from - 7 * DAY) {
233
+ p += Math.floor((from - 7 * DAY - periodStart) / (7 * DAY) / r.interval) * r.interval;
234
+ periodStart = week0 + p * 7 * DAY;
235
+ }
236
+ const wds = r.byDay?.length ? r.byDay.map((b) => b.wd) : [weekday(dt)];
237
+ days = wds.map((wd) => periodStart + ((wd - r.wkst + 7) % 7) * DAY).filter(inMonth);
238
+ break;
239
+ }
240
+ case 'MONTHLY': {
241
+ const y = dtY + Math.floor((dtM + p) / 12);
242
+ const m = (dtM + p) % 12;
243
+ periodStart = Date.UTC(y, m, 1);
244
+ days = r.byMonth?.length && !r.byMonth.includes(m) ? [] : monthDays(y, m, r, dtD);
245
+ break;
246
+ }
247
+ case 'YEARLY': {
248
+ const y = dtY + p;
249
+ periodStart = Date.UTC(y, 0, 1);
250
+ if (r.byDay?.length && !r.byMonth?.length && !r.byMonthDay?.length) {
251
+ // BYDAY alone ranges over the whole year (20MO = 20th Monday).
252
+ const len = (Date.UTC(y + 1, 0, 1) - periodStart) / DAY;
253
+ days = pickWeekdays(periodStart, len, r.byDay).map((d) => periodStart + d * DAY);
254
+ }
255
+ else {
256
+ const months = r.byMonth?.length ? r.byMonth : r.byMonthDay?.length ? [...Array(12).keys()] : [dtM];
257
+ days = months.flatMap((m) => monthDays(y, m, r, dtD));
258
+ }
259
+ break;
260
+ }
261
+ }
262
+ if (periodStart > to)
263
+ return;
264
+ if (r.until && periodStart > r.until.wall + DAY)
265
+ return;
266
+ let walls = [...new Set(days)].sort((a, b) => a - b).map((d) => d + tod);
267
+ if (r.bySetPos?.length) {
268
+ const all = walls;
269
+ walls = r.bySetPos.map((i) => all[i > 0 ? i - 1 : all.length + i]).filter((w) => w != null).sort((a, b) => a - b);
270
+ }
271
+ for (const w of walls) {
272
+ if (w <= dt)
273
+ continue;
274
+ yield w;
275
+ if (++emitted >= limit)
276
+ return;
277
+ }
278
+ }
279
+ }
280
+ // ── VTIMEZONE ───────────────────────────────────────────
281
+ const parseOffset = (v) => {
282
+ const m = /^([+-])(\d{2})(\d{2})(\d{2})?$/.exec(v.trim());
283
+ return m ? (m[1] === '-' ? -1 : 1) * ((+m[2] * 60 + +m[3]) * 60 + +(m[4] ?? 0)) * 1000 : 0;
284
+ };
285
+ /** A zone built from a VTIMEZONE's STANDARD/DAYLIGHT onsets (Outlook's Windows-named zones). */
286
+ function vtimezoneZone(tz) {
287
+ const parts = tz.children.flatMap((c) => {
288
+ const start = parseWall(prop(c, 'DTSTART')?.value ?? '');
289
+ const to = prop(c, 'TZOFFSETTO');
290
+ if (start == null || !to)
291
+ return [];
292
+ const rrule = prop(c, 'RRULE');
293
+ return [{
294
+ start,
295
+ to: parseOffset(to.value),
296
+ from: parseOffset(prop(c, 'TZOFFSETFROM')?.value ?? to.value),
297
+ rule: rrule ? parseRule(rrule.value, () => UTC_ZONE) : undefined,
298
+ rdates: props(c, 'RDATE').flatMap((p) => p.value.split(',')).map(parseWall).filter((w) => w != null),
299
+ }];
300
+ });
301
+ if (!parts.length)
302
+ return undefined;
303
+ const byYear = new Map();
304
+ const onsets = (year) => {
305
+ let list = byYear.get(year);
306
+ if (!list) {
307
+ const from = Date.UTC(year - 1, 0, 1);
308
+ const to = Date.UTC(year + 1, 0, 1);
309
+ list = [];
310
+ for (const part of parts) {
311
+ const walls = part.rule ? expandRule(part.start, part.rule, from, to) : [part.start];
312
+ for (const at of walls)
313
+ if (at >= from && at < to)
314
+ list.push({ at, to: part.to });
315
+ for (const at of part.rdates)
316
+ if (at >= from && at < to)
317
+ list.push({ at, to: part.to });
318
+ }
319
+ list.sort((a, b) => a.at - b.at);
320
+ byYear.set(year, list);
321
+ }
322
+ return list;
323
+ };
324
+ const first = parts.reduce((a, b) => (b.start < a.start ? b : a));
325
+ return (wall) => {
326
+ const list = onsets(new Date(wall).getUTCFullYear());
327
+ let offset = first.from;
328
+ for (const o of list)
329
+ if (o.at <= wall)
330
+ offset = o.to;
331
+ return wall - offset;
332
+ };
333
+ }
334
+ const instant = (t) => t.zone(t.wall);
335
+ /** An all-day date is midnight in the browser's zone; a time is its instant. */
336
+ const toDate = (t) => new Date(t.date ? LOCAL_ZONE(t.wall) : instant(t));
337
+ /** UNTIL is inclusive; a date UNTIL keeps that whole day. */
338
+ function pastUntil(t, until) {
339
+ if (!until)
340
+ return false;
341
+ if (until.date)
342
+ return t.wall >= until.wall + DAY;
343
+ return t.date ? t.wall > until.wall : instant(t) > instant(until);
344
+ }
345
+ const STATUS = { CANCELLED: 'cancelled', TENTATIVE: 'tentative', CONFIRMED: 'confirmed' };
346
+ /**
347
+ * Parse an iCalendar text (.ics, .ical, .ifb; a .vcs reads as far as its
348
+ * events go). Recurring events are expanded per range by `events(range)`.
349
+ * A malformed line or event is skipped, never thrown.
350
+ */
351
+ export function parseIcs(input) {
352
+ const root = parseComponents(input);
353
+ const cal = root.children.find((c) => c.name === 'VCALENDAR') ?? root;
354
+ const zones = new Map();
355
+ for (const tz of cal.children.filter((c) => c.name === 'VTIMEZONE')) {
356
+ const id = prop(tz, 'TZID')?.value;
357
+ const zone = id ? vtimezoneZone(tz) : undefined;
358
+ if (id && zone)
359
+ zones.set(id, zone);
360
+ }
361
+ // Floating times read in the calendar's own zone when it names one (Google does).
362
+ const calTz = prop(cal, 'X-WR-TIMEZONE')?.value;
363
+ const floating = (calTz && ianaZone(calTz)) || LOCAL_ZONE;
364
+ const zoneOf = (tzid) => {
365
+ if (!tzid)
366
+ return floating;
367
+ const id = tzid.replace(/^\//, '');
368
+ return ianaZone(id) ?? zones.get(tzid) ?? floating;
369
+ };
370
+ const timeOf = (p) => {
371
+ if (!p)
372
+ return undefined;
373
+ const wall = parseWall(p.value);
374
+ if (wall == null)
375
+ return undefined;
376
+ return {
377
+ wall,
378
+ date: p.params.VALUE === 'DATE' || !p.value.includes('T'),
379
+ zone: p.value.trim().endsWith('Z') ? UTC_ZONE : zoneOf(p.params.TZID),
380
+ };
381
+ };
382
+ const timesOf = (ps) => ps.flatMap((p) => p.value.split(',').map((value) => timeOf({ ...p, value }))).filter((t) => !!t);
383
+ const defs = [];
384
+ for (const ve of cal.children.filter((c) => c.name === 'VEVENT')) {
385
+ const start = timeOf(prop(ve, 'DTSTART'));
386
+ if (!start)
387
+ continue;
388
+ const end = timeOf(prop(ve, 'DTEND'));
389
+ const dur = prop(ve, 'DURATION');
390
+ let duration = end
391
+ ? start.date ? end.wall - start.wall : instant(end) - instant(start)
392
+ : dur ? (parseDuration(dur.value) ?? 0) : start.date ? DAY : 0;
393
+ if (duration < 0)
394
+ duration = 0;
395
+ const rrule = prop(ve, 'RRULE');
396
+ const recurrenceId = timeOf(prop(ve, 'RECURRENCE-ID'));
397
+ const status = prop(ve, 'STATUS')?.value.trim().toUpperCase();
398
+ const data = { readOnly: true };
399
+ const description = text(ve, 'DESCRIPTION');
400
+ if (description)
401
+ data.description = description;
402
+ const url = prop(ve, 'URL')?.value.trim();
403
+ if (url)
404
+ data.url = url;
405
+ defs.push({
406
+ uid: prop(ve, 'UID')?.value.trim() || `ics-${defs.length}`,
407
+ start,
408
+ duration,
409
+ rule: rrule ? parseRule(rrule.value, zoneOf) : undefined,
410
+ rdates: timesOf(props(ve, 'RDATE')),
411
+ exdates: new Set(timesOf(props(ve, 'EXDATE')).map(instant)),
412
+ recurrence: recurrenceId,
413
+ title: text(ve, 'SUMMARY')?.trim() || '(No title)',
414
+ location: text(ve, 'LOCATION')?.trim() || undefined,
415
+ status: status ? STATUS[status] : undefined,
416
+ category: text(ve, 'CATEGORIES')?.split(',')[0]?.trim() || undefined,
417
+ color: prop(ve, 'COLOR')?.value.trim() || undefined,
418
+ data,
419
+ });
420
+ }
421
+ // RECURRENCE-ID rows replace one occurrence of their master.
422
+ const overrides = new Map();
423
+ for (const d of defs) {
424
+ if (!d.recurrence)
425
+ continue;
426
+ let m = overrides.get(d.uid);
427
+ if (!m)
428
+ overrides.set(d.uid, (m = new Map()));
429
+ m.set(instant(d.recurrence), d);
430
+ }
431
+ const masters = defs.filter((d) => !d.recurrence);
432
+ const mastered = new Set(masters.map((d) => d.uid));
433
+ // An override whose master isn't in the file still stands alone.
434
+ const orphans = defs.filter((d) => d.recurrence && !mastered.has(d.uid));
435
+ const calColor = prop(cal, 'X-APPLE-CALENDAR-COLOR')?.value.trim() || prop(cal, 'COLOR')?.value.trim() || undefined;
436
+ const toEvent = (d, start, id) => {
437
+ const s = toDate(start);
438
+ const e = start.date
439
+ ? toDate({ ...start, wall: start.wall + d.duration })
440
+ : new Date(s.getTime() + d.duration);
441
+ const ev = { id, title: d.title, start: s, end: e, externalId: d.uid, data: { ...d.data } };
442
+ if (start.date)
443
+ ev.allDay = true;
444
+ if (d.location)
445
+ ev.location = d.location;
446
+ if (d.status)
447
+ ev.status = d.status;
448
+ if (d.category)
449
+ ev.category = d.category;
450
+ if (d.color)
451
+ ev.color = d.color;
452
+ return ev;
453
+ };
454
+ const key = (t) => new Date(t.wall).toISOString().replace(/[-:]|\.\d+Z$/g, '').replace(t.date ? /T.*$/ : /$/, '');
455
+ return {
456
+ name: text(cal, 'X-WR-CALNAME') ?? text(cal, 'NAME'),
457
+ color: calColor,
458
+ events(range) {
459
+ const out = [];
460
+ const lo = range.start.getTime();
461
+ const hi = range.end.getTime();
462
+ const push = (ev) => {
463
+ const s = ev.start.getTime();
464
+ const e = Math.max(ev.end.getTime(), s + 1); // a zero-length event still sits at its time
465
+ if (s < hi && e > lo)
466
+ out.push(ev);
467
+ };
468
+ for (const d of masters) {
469
+ if (!d.rule && !d.rdates.length) {
470
+ push(toEvent(d, d.start, d.uid));
471
+ continue;
472
+ }
473
+ const own = overrides.get(d.uid);
474
+ const seen = new Set();
475
+ const occurrence = (t) => {
476
+ const at = instant(t);
477
+ if (seen.has(at) || d.exdates.has(at))
478
+ return;
479
+ seen.add(at);
480
+ const id = `${d.uid}--${key(t)}`;
481
+ const over = own?.get(at);
482
+ push(over ? toEvent(over, over.start, id) : toEvent(d, t, id));
483
+ };
484
+ // Walls run ±14 h from instants, and an occurrence can start a
485
+ // duration before the range: widen by both, trim in push().
486
+ const from = lo - d.duration - 2 * DAY + (d.start.wall - instant(d.start));
487
+ const to = hi + 2 * DAY + (d.start.wall - instant(d.start));
488
+ if (d.rule) {
489
+ for (const wall of expandRule(d.start.wall, d.rule, from, to)) {
490
+ const t = { ...d.start, wall };
491
+ if (wall > to || pastUntil(t, d.rule.until))
492
+ break;
493
+ if (wall >= from)
494
+ occurrence(t);
495
+ }
496
+ }
497
+ else {
498
+ occurrence(d.start);
499
+ }
500
+ for (const t of d.rdates)
501
+ occurrence(t);
502
+ // An override moved into the range from an occurrence outside it.
503
+ for (const over of own?.values() ?? []) {
504
+ const at = instant(over.recurrence);
505
+ if (seen.has(at) || d.exdates.has(at))
506
+ continue;
507
+ push(toEvent(over, over.start, `${d.uid}--${key(over.recurrence)}`));
508
+ }
509
+ }
510
+ for (const d of orphans)
511
+ push(toEvent(d, d.start, `${d.uid}--${key(d.recurrence)}`));
512
+ return out;
513
+ },
514
+ };
515
+ }
516
+ /**
517
+ * A read-only CalendarAdapter over iCalendar data: a file's text, a feed URL,
518
+ * or a function that loads the text. Each occurrence carries `data.readOnly`,
519
+ * and a write throws CalendarReadOnlyError, so a composite hands a drag to
520
+ * `oneventmove`.
521
+ *
522
+ * Feeds from Google, Outlook and iCloud send no CORS headers: in a browser,
523
+ * fetch them through your own server route.
524
+ */
525
+ export function createIcsAdapter(source, options = {}) {
526
+ let parsed = typeof source === 'string' ? parseIcs(source) : undefined;
527
+ let loading;
528
+ let loadedAt = 0;
529
+ // Ids this adapter has handed out: a write to one is refused as read-only,
530
+ // any other id is "not mine" (a composite asks its next child).
531
+ const known = new Set();
532
+ const load = () => {
533
+ if (typeof source === 'string')
534
+ return Promise.resolve(parsed);
535
+ const stale = typeof source === 'object' && source.refreshInterval != null && Date.now() - loadedAt > source.refreshInterval;
536
+ if (!loading || (stale && parsed)) {
537
+ loadedAt = Date.now();
538
+ const request = typeof source === 'function'
539
+ ? source()
540
+ : (source.fetch ?? fetch)(source.url.replace(/^webcal:/i, 'https:')).then((r) => {
541
+ if (!r.ok)
542
+ throw new Error(`ICS feed ${source.url}: HTTP ${r.status}`);
543
+ return r.text();
544
+ });
545
+ loading = request.then((t) => (parsed = parseIcs(t)), (e) => {
546
+ loading = undefined; // the next load tries again
547
+ throw e;
548
+ });
549
+ }
550
+ return parsed && !stale ? Promise.resolve(parsed) : loading;
551
+ };
552
+ const paint = (events, cal) => {
553
+ const color = options.color ?? cal.color;
554
+ for (const e of events)
555
+ known.add(e.id);
556
+ return color ? events.map((e) => (e.color ? e : { ...e, color })) : events;
557
+ };
558
+ return {
559
+ fetchEventsSync(range) {
560
+ return parsed && typeof source === 'string' ? paint(parsed.events(range), parsed) : undefined;
561
+ },
562
+ async fetchEvents(range) {
563
+ const cal = await load();
564
+ return paint(cal.events(range), cal);
565
+ },
566
+ async updateEvent(id) {
567
+ if (!known.has(id))
568
+ throw new EventNotFoundError(id);
569
+ throw new CalendarReadOnlyError(`read-only: ${id} comes from an iCalendar source`);
570
+ },
571
+ };
572
+ }
573
+ // ── Export ──────────────────────────────────────────────
574
+ function escapeText(v) {
575
+ return v.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
576
+ }
577
+ /** Fold a content line at 75 octets (RFC 5545 §3.1), never inside a character. */
578
+ function fold(line) {
579
+ const out = [];
580
+ let cur = '';
581
+ let bytes = 0;
582
+ for (const ch of line) {
583
+ const cp = ch.codePointAt(0);
584
+ const n = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
585
+ if (bytes + n > (out.length ? 74 : 75)) {
586
+ out.push(cur);
587
+ cur = '';
588
+ bytes = 0;
589
+ }
590
+ cur += ch;
591
+ bytes += n;
592
+ }
593
+ out.push(cur);
594
+ return out.join('\r\n ');
595
+ }
596
+ const pad = (n) => String(n).padStart(2, '0');
597
+ const utcStamp = (d) => d.toISOString().replace(/[-:]|\.\d+/g, '');
598
+ const localDate = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
599
+ /**
600
+ * Write events as an iCalendar file that Google Calendar, Apple Calendar and
601
+ * Outlook import (or subscribe to, served from a URL). Times go out in UTC;
602
+ * all-day events as dates. UID is `externalId ?? id`. A string
603
+ * `data.description` / `data.url` becomes DESCRIPTION / URL.
604
+ */
605
+ export function toIcs(events, options = {}) {
606
+ const stamp = utcStamp(new Date());
607
+ const lines = [
608
+ 'BEGIN:VCALENDAR',
609
+ 'VERSION:2.0',
610
+ `PRODID:${options.prodId ?? '-//nomideusz//svelte-calendar//EN'}`,
611
+ 'CALSCALE:GREGORIAN',
612
+ 'METHOD:PUBLISH',
613
+ ];
614
+ if (options.name)
615
+ lines.push(`X-WR-CALNAME:${escapeText(options.name)}`);
616
+ for (const ev of events) {
617
+ lines.push('BEGIN:VEVENT', `UID:${escapeText(ev.externalId ?? ev.id)}`, `DTSTAMP:${stamp}`);
618
+ if (ev.allDay) {
619
+ // An all-day end is exclusive: a one-day event ends the next midnight.
620
+ const end = ev.end > ev.start ? ev.end : new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate() + 1);
621
+ const endDay = end.getHours() || end.getMinutes() ? new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1) : end;
622
+ lines.push(`DTSTART;VALUE=DATE:${localDate(ev.start)}`, `DTEND;VALUE=DATE:${localDate(endDay)}`);
623
+ }
624
+ else {
625
+ lines.push(`DTSTART:${utcStamp(ev.start)}`, `DTEND:${utcStamp(ev.end)}`);
626
+ }
627
+ lines.push(`SUMMARY:${escapeText(ev.title)}`);
628
+ if (ev.location)
629
+ lines.push(`LOCATION:${escapeText(ev.location)}`);
630
+ const description = ev.data?.description ?? ev.subtitle;
631
+ if (typeof description === 'string' && description)
632
+ lines.push(`DESCRIPTION:${escapeText(description)}`);
633
+ if (typeof ev.data?.url === 'string')
634
+ lines.push(`URL:${ev.data.url}`);
635
+ if (ev.category)
636
+ lines.push(`CATEGORIES:${escapeText(ev.category)}`);
637
+ if (ev.status === 'cancelled' || ev.status === 'tentative')
638
+ lines.push(`STATUS:${ev.status.toUpperCase()}`);
639
+ lines.push('END:VEVENT');
640
+ }
641
+ lines.push('END:VCALENDAR');
642
+ return lines.map(fold).join('\r\n') + '\r\n';
643
+ }
644
+ // ── One-event links ─────────────────────────────────────
645
+ const detailsOf = (ev) => {
646
+ const d = ev.data?.description ?? ev.subtitle;
647
+ return typeof d === 'string' ? d : '';
648
+ };
649
+ /** An "add to Google Calendar" link for one event. */
650
+ export function googleCalendarUrl(ev) {
651
+ const dates = ev.allDay
652
+ ? `${localDate(ev.start)}/${localDate(ev.end > ev.start ? ev.end : new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate() + 1))}`
653
+ : `${utcStamp(ev.start)}/${utcStamp(ev.end)}`;
654
+ const q = new URLSearchParams({ action: 'TEMPLATE', text: ev.title, dates });
655
+ if (ev.location)
656
+ q.set('location', ev.location);
657
+ const details = detailsOf(ev);
658
+ if (details)
659
+ q.set('details', details);
660
+ return `https://calendar.google.com/calendar/render?${q}`;
661
+ }
662
+ /**
663
+ * An "add to Outlook" link for one event: outlook.live.com (personal), or
664
+ * `work: true` for outlook.office.com (Microsoft 365).
665
+ */
666
+ export function outlookCalendarUrl(ev, options = {}) {
667
+ const iso = (d) => (ev.allDay ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : d.toISOString());
668
+ const q = new URLSearchParams({
669
+ path: '/calendar/action/compose',
670
+ rru: 'addevent',
671
+ subject: ev.title,
672
+ startdt: iso(ev.start),
673
+ enddt: iso(ev.end),
674
+ allday: String(!!ev.allDay),
675
+ });
676
+ if (ev.location)
677
+ q.set('location', ev.location);
678
+ const details = detailsOf(ev);
679
+ if (details)
680
+ q.set('body', details);
681
+ return `https://${options.work ? 'outlook.office.com' : 'outlook.live.com'}/calendar/0/deeplink/compose?${q}`;
682
+ }
@@ -12,4 +12,6 @@ export { withInitialEvents } from './seeded.js';
12
12
  export type { CompositeAdapterOptions } from './composite.js';
13
13
  export { createJmapAdapter } from './jmap.js';
14
14
  export type { JmapClient, JmapCalendarAdapterOptions } from './jmap.js';
15
+ export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './ics.js';
16
+ export type { IcsCalendar, IcsSource, IcsAdapterOptions, ToIcsOptions } from './ics.js';
15
17
  export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './errors.js';
@@ -5,4 +5,5 @@ export { createMappedAdapter } from './mapped.js';
5
5
  export { createCompositeAdapter } from './composite.js';
6
6
  export { withInitialEvents } from './seeded.js';
7
7
  export { createJmapAdapter } from './jmap.js';
8
+ export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './ics.js';
8
9
  export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './errors.js';
package/dist/index.d.ts CHANGED
@@ -8,8 +8,9 @@ export type { CalendarContext } from './views/shared/context.svelte.js';
8
8
  export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
9
9
  export type { EventStore, ViewState, ViewStateOptions, CalendarViewId, BuiltInViewId, ViewMode, Selection as CalendarSelection, DragState, DragMode, DragPayload, } from './engine/index.js';
10
10
  export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
11
+ export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './adapters/index.js';
11
12
  export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './adapters/index.js';
12
- export type { CalendarAdapter, WritableCalendarAdapter, DateRange, MemoryAdapterOptions, RestAdapterOptions, RecurringEvent, RecurringAdapterOptions, FieldMapping, MappedAdapterOptions, MutationHandler, CompositeAdapterOptions, JmapClient, JmapCalendarAdapterOptions, } from './adapters/index.js';
13
+ export type { CalendarAdapter, WritableCalendarAdapter, DateRange, MemoryAdapterOptions, RestAdapterOptions, RecurringEvent, RecurringAdapterOptions, FieldMapping, MappedAdapterOptions, MutationHandler, CompositeAdapterOptions, JmapClient, JmapCalendarAdapterOptions, IcsCalendar, IcsSource, IcsAdapterOptions, ToIcsOptions, } from './adapters/index.js';
13
14
  export { createClock, sod, startOfWeek, addDaysMs, diffDays, fmtH, fmtHParts, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
14
15
  export type { Clock, TimelineEvent, BlockedSlot, DaySegment, CalendarLabels, EventStatus, } from './core/index.js';
15
16
  export { auto, neutral, midnight, presets } from './theme/index.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { useCalendarContext } from './views/shared/context.svelte.js';
11
11
  export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
12
12
  // ─── Adapters ───────────────────────────────────────────
13
13
  export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
14
+ export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './adapters/index.js';
14
15
  export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './adapters/index.js';
15
16
  // ─── Core: clock, time, locale, types ───────────────────
16
17
  export { createClock, sod, startOfWeek, addDaysMs, diffDays, fmtH, fmtHParts, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
package/dist/text-fit.js CHANGED
@@ -194,19 +194,29 @@ export function typeset(text) {
194
194
  // hole in the paragraph. Set as nowrap, a misfit is plain overflow, which
195
195
  // is measurable: re-break a touch narrower, and only then hand the text
196
196
  // back to the browser.
197
+ // The lines are for the eye: their break hyphens ("co-" / "dzienne") are
198
+ // hidden from assistive tech, which reads the text whole from a visually
199
+ // hidden copy. Each line keeps the space it broke at (a trailing space
200
+ // is dropped at a nowrap line's end), so find and copy see words.
201
+ const whole = document.createElement('span');
202
+ whole.textContent = text;
203
+ whole.style.cssText =
204
+ 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap';
197
205
  const set = (w) => {
198
206
  const lines = breakLines(text, font, w);
199
- el.replaceChildren(...lines.map((l, i) => {
207
+ const spans = lines.map((l, i) => {
200
208
  const s = document.createElement('span');
209
+ s.setAttribute('aria-hidden', 'true');
201
210
  s.style.display = 'block';
202
211
  s.style.whiteSpace = 'nowrap';
203
212
  s.style.hyphens = 'manual';
204
213
  s.style.textAlign = 'justify';
205
214
  s.style.textAlignLast = i === lines.length - 1 ? 'auto' : 'justify';
206
- s.textContent = l;
215
+ s.textContent = i < lines.length - 1 && !l.endsWith('-') ? l + ' ' : l;
207
216
  return s;
208
- }));
209
- for (const s of el.children)
217
+ });
218
+ el.replaceChildren(whole, ...spans);
219
+ for (const s of spans)
210
220
  if (s.scrollWidth > s.clientWidth + 1)
211
221
  return false;
212
222
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-calendar",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "A themeable Svelte 5 calendar: day, week, scrolling-week and month views, agenda lists, touch-first mobile layouts, drag-and-drop, timezones, a headless API and an embeddable web component.",
5
5
  "type": "module",
6
6
  "sideEffects": [