@acms-subir/core 0.0.1

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.
Files changed (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,402 @@
1
+ // ICS ingestion for the calendar card: fetch a public feed (Google Calendar's
2
+ // basic.ics, or any ICS URL), parse VEVENTs, expand recurrences over a bounded
3
+ // window, and hand back flat display-ready events. Runs at request time inside
4
+ // expandDynamicBlocks, so results are cached per-isolate for 5 minutes — the
5
+ // same spirit as middleware's redirectCache. Recurrence covers what real
6
+ // church/team calendars use (DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT,
7
+ // UNTIL, BYDAY incl. nth-weekday, BYMONTHDAY, EXDATE, RECURRENCE-ID
8
+ // overrides); exotic RRULE parts are ignored rather than guessed at.
9
+ import { fetchPublicUrl } from '../net';
10
+
11
+ export interface CalendarEvent {
12
+ start: number; // UTC ms
13
+ end: number; // UTC ms, exclusive
14
+ allDay: boolean;
15
+ days: number; // calendar days covered (all-day spans); timed events = 1
16
+ title: string;
17
+ location: string;
18
+ description: string;
19
+ cal: number; // index into the card's sources list (drives the dot color)
20
+ }
21
+
22
+ export interface CalendarWindow {
23
+ displayTz: string; // site timezone; '' falls back to UTC
24
+ windowStartMs: number;
25
+ windowEndMs: number;
26
+ now: number;
27
+ }
28
+
29
+ interface IcsTime {
30
+ y: number; m: number; d: number; hh: number; mm: number; ss: number;
31
+ tz: string | null;
32
+ dateOnly: boolean;
33
+ utc: boolean;
34
+ }
35
+
36
+ interface VEvent {
37
+ uid: string;
38
+ title: string;
39
+ location: string;
40
+ description: string;
41
+ cancelled: boolean;
42
+ start: IcsTime;
43
+ end: IcsTime | null;
44
+ rrule: string;
45
+ exdates: IcsTime[];
46
+ recurrenceId: IcsTime | null;
47
+ }
48
+
49
+ const DAY = 86_400_000;
50
+
51
+ /* ---------------- timezone math (no dependencies) ---------------- */
52
+
53
+ const fmtCache = new Map<string, Intl.DateTimeFormat>();
54
+ function tzFormatter(tz: string): Intl.DateTimeFormat | null {
55
+ let f = fmtCache.get(tz);
56
+ if (f) return f;
57
+ try {
58
+ f = new Intl.DateTimeFormat('en-US', {
59
+ timeZone: tz, hour12: false,
60
+ year: 'numeric', month: '2-digit', day: '2-digit',
61
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
62
+ });
63
+ } catch {
64
+ return null; // unknown TZID (Outlook's "Customized Time Zone" etc.)
65
+ }
66
+ fmtCache.set(tz, f);
67
+ return f;
68
+ }
69
+
70
+ export function tzParts(ms: number, tz: string): { y: number; m: number; d: number; hh: number; mm: number; ss: number } {
71
+ const f = tzFormatter(tz) ?? tzFormatter('UTC')!;
72
+ const p: Record<string, number> = {};
73
+ for (const part of f.formatToParts(ms)) if (part.type !== 'literal') p[part.type] = Number(part.value);
74
+ return { y: p.year, m: p.month, d: p.day, hh: p.hour === 24 ? 0 : p.hour, mm: p.minute, ss: p.second };
75
+ }
76
+
77
+ // Wall-clock in `tz` → UTC ms. Two-pass offset guess handles DST boundaries.
78
+ export function zonedToUtc(y: number, m: number, d: number, hh: number, mm: number, ss: number, tz: string): number {
79
+ const guess = Date.UTC(y, m - 1, d, hh, mm, ss);
80
+ if (!tzFormatter(tz)) return guess;
81
+ const offsetAt = (ms: number) => {
82
+ const p = tzParts(ms, tz);
83
+ return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm, p.ss) - ms;
84
+ };
85
+ const utc = guess - offsetAt(guess);
86
+ return guess - offsetAt(utc);
87
+ }
88
+
89
+ const dayNum = (y: number, m: number, d: number) => Math.round(Date.UTC(y, m - 1, d) / DAY);
90
+ const fromDayNum = (n: number) => {
91
+ const dt = new Date(n * DAY);
92
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
93
+ };
94
+ const dowOfDay = (n: number) => (((n + 4) % 7) + 7) % 7; // epoch day 0 = Thursday
95
+ const daysInMonth = (y: number, m: number) => new Date(Date.UTC(y, m, 0)).getUTCDate();
96
+
97
+ /* ---------------- ICS parsing ---------------- */
98
+
99
+ function unescapeText(s: string): string {
100
+ return s.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1');
101
+ }
102
+
103
+ // "NAME;PARAM=V;PARAM2="Q V":value" → parts, respecting quoted params
104
+ function splitProp(line: string): { name: string; params: Record<string, string>; value: string } | null {
105
+ let inQ = false;
106
+ let colon = -1;
107
+ for (let i = 0; i < line.length; i++) {
108
+ const c = line[i];
109
+ if (c === '"') inQ = !inQ;
110
+ else if (c === ':' && !inQ) { colon = i; break; }
111
+ }
112
+ if (colon < 0) return null;
113
+ const head = line.slice(0, colon);
114
+ const value = line.slice(colon + 1);
115
+ const segs: string[] = [];
116
+ let cur = '';
117
+ inQ = false;
118
+ for (const c of head) {
119
+ if (c === '"') inQ = !inQ;
120
+ if (c === ';' && !inQ) { segs.push(cur); cur = ''; } else cur += c;
121
+ }
122
+ segs.push(cur);
123
+ const params: Record<string, string> = {};
124
+ for (const seg of segs.slice(1)) {
125
+ const eq = seg.indexOf('=');
126
+ if (eq > 0) params[seg.slice(0, eq).toUpperCase()] = seg.slice(eq + 1).replace(/^"|"$/g, '');
127
+ }
128
+ return { name: segs[0].toUpperCase(), params, value };
129
+ }
130
+
131
+ function parseIcsDate(value: string, params: Record<string, string>): IcsTime | null {
132
+ const v = value.trim();
133
+ const dateOnly = params.VALUE === 'DATE' || /^\d{8}$/.test(v);
134
+ if (dateOnly) {
135
+ const m = v.match(/^(\d{4})(\d{2})(\d{2})/);
136
+ if (!m) return null;
137
+ return { y: +m[1], m: +m[2], d: +m[3], hh: 0, mm: 0, ss: 0, tz: null, dateOnly: true, utc: false };
138
+ }
139
+ const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
140
+ if (!m) return null;
141
+ return {
142
+ y: +m[1], m: +m[2], d: +m[3], hh: +m[4], mm: +m[5], ss: +m[6],
143
+ tz: params.TZID ?? null, dateOnly: false, utc: m[7] === 'Z',
144
+ };
145
+ }
146
+
147
+ export function parseICS(text: string): { vevents: VEvent[]; calTz: string } {
148
+ // unfold: a CRLF (or LF) followed by a space/tab continues the previous line
149
+ const lines = text.replace(/\r\n[ \t]|\n[ \t]|\r[ \t]/g, '').split(/\r\n|\n|\r/);
150
+ const vevents: VEvent[] = [];
151
+ let calTz = '';
152
+ let ev: Partial<VEvent> & { exdates: IcsTime[] } | null = null;
153
+ for (const line of lines) {
154
+ if (line === 'BEGIN:VEVENT') {
155
+ ev = { uid: '', title: '', location: '', description: '', cancelled: false, rrule: '', exdates: [], recurrenceId: null };
156
+ continue;
157
+ }
158
+ if (line === 'END:VEVENT') {
159
+ if (ev?.start) vevents.push(ev as VEvent);
160
+ ev = null;
161
+ continue;
162
+ }
163
+ const prop = splitProp(line);
164
+ if (!prop) continue;
165
+ if (!ev) {
166
+ if (prop.name === 'X-WR-TIMEZONE') calTz = prop.value.trim();
167
+ continue;
168
+ }
169
+ switch (prop.name) {
170
+ case 'UID': ev.uid = prop.value.trim(); break;
171
+ case 'SUMMARY': ev.title = unescapeText(prop.value).trim(); break;
172
+ case 'LOCATION': ev.location = unescapeText(prop.value).trim(); break;
173
+ case 'DESCRIPTION':
174
+ // Google descriptions may carry HTML; events render as text everywhere
175
+ ev.description = unescapeText(prop.value).replace(/<[^>]+>/g, ' ').replace(/[ \t]+/g, ' ').trim().slice(0, 600);
176
+ break;
177
+ case 'STATUS': ev.cancelled = prop.value.trim().toUpperCase() === 'CANCELLED'; break;
178
+ case 'DTSTART': ev.start = parseIcsDate(prop.value, prop.params) ?? undefined; break;
179
+ case 'DTEND': ev.end = parseIcsDate(prop.value, prop.params); break;
180
+ case 'RRULE': ev.rrule = prop.value.trim(); break;
181
+ case 'RECURRENCE-ID': ev.recurrenceId = parseIcsDate(prop.value, prop.params); break;
182
+ case 'EXDATE':
183
+ for (const part of prop.value.split(',')) {
184
+ const t = parseIcsDate(part, prop.params);
185
+ if (t) ev.exdates.push(t);
186
+ }
187
+ break;
188
+ }
189
+ }
190
+ return { vevents, calTz };
191
+ }
192
+
193
+ /* ---------------- recurrence expansion ---------------- */
194
+
195
+ interface Rule {
196
+ freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
197
+ interval: number;
198
+ count: number | null;
199
+ until: IcsTime | null;
200
+ byday: { ord: number; dow: number }[];
201
+ bymonthday: number[];
202
+ }
203
+
204
+ const DOW_CODES: Record<string, number> = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
205
+
206
+ function parseRRule(s: string): Rule | null {
207
+ const parts: Record<string, string> = {};
208
+ for (const seg of s.split(';')) {
209
+ const eq = seg.indexOf('=');
210
+ if (eq > 0) parts[seg.slice(0, eq).toUpperCase()] = seg.slice(eq + 1);
211
+ }
212
+ const freq = parts.FREQ as Rule['freq'];
213
+ if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(freq)) return null;
214
+ const byday = (parts.BYDAY ?? '')
215
+ .split(',')
216
+ .map((t) => {
217
+ const m = t.trim().match(/^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/);
218
+ return m ? { ord: m[1] ? Number(m[1]) : 0, dow: DOW_CODES[m[2]] } : null;
219
+ })
220
+ .filter((x): x is { ord: number; dow: number } => x !== null);
221
+ return {
222
+ freq,
223
+ interval: Math.max(1, Number(parts.INTERVAL) || 1),
224
+ count: parts.COUNT ? Math.max(1, Number(parts.COUNT) || 1) : null,
225
+ until: parts.UNTIL ? parseIcsDate(parts.UNTIL, {}) : null,
226
+ byday,
227
+ bymonthday: (parts.BYMONTHDAY ?? '').split(',').map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= 31),
228
+ };
229
+ }
230
+
231
+ function timeToMs(t: IcsTime, timedTz: string, displayTz: string): number {
232
+ if (t.dateOnly) return zonedToUtc(t.y, t.m, t.d, 0, 0, 0, displayTz); // all-day: midnight in the display tz, so day bucketing is exact
233
+ if (t.utc) return Date.UTC(t.y, t.m - 1, t.d, t.hh, t.mm, t.ss);
234
+ return zonedToUtc(t.y, t.m, t.d, t.hh, t.mm, t.ss, t.tz ?? timedTz);
235
+ }
236
+
237
+ function expandEvent(
238
+ ev: VEvent,
239
+ calTz: string,
240
+ win: CalendarWindow,
241
+ calIdx: number,
242
+ skipStarts: Set<number>,
243
+ out: CalendarEvent[]
244
+ ): void {
245
+ const displayTz = win.displayTz || 'UTC';
246
+ const timedTz = calTz || displayTz;
247
+ if (ev.cancelled) return;
248
+ const startMs = timeToMs(ev.start, timedTz, displayTz);
249
+ const endMs = ev.end ? timeToMs(ev.end, timedTz, displayTz) : NaN;
250
+ const allDay = ev.start.dateOnly;
251
+ const spanDays = allDay ? Math.max(1, Number.isFinite(endMs) ? Math.round((endMs - startMs) / DAY) : 1) : 1;
252
+ const durMs = allDay ? spanDays * DAY : Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : 3_600_000;
253
+
254
+ const push = (s: number) => {
255
+ const e = allDay ? s + spanDays * DAY : s + durMs;
256
+ if (e <= win.windowStartMs || s >= win.windowEndMs) return;
257
+ out.push({ start: s, end: e, allDay, days: spanDays, title: ev.title || '(untitled)', location: ev.location, description: ev.description, cal: calIdx });
258
+ };
259
+
260
+ const rule = ev.rrule && !ev.recurrenceId ? parseRRule(ev.rrule) : null;
261
+ if (!rule) {
262
+ if (!skipStarts.has(startMs)) push(startMs);
263
+ return;
264
+ }
265
+
266
+ // exceptions, matched by occurrence start (plus local-date for all-day)
267
+ const exMs = new Set(ev.exdates.map((t) => timeToMs(t, timedTz, displayTz)));
268
+
269
+ // local reference: DTSTART's wall clock in the tz occurrences are generated in
270
+ const occTz = ev.start.dateOnly ? displayTz : ev.start.tz ?? timedTz;
271
+ const ref = ev.start.dateOnly
272
+ ? { y: ev.start.y, m: ev.start.m, d: ev.start.d, hh: 0, mm: 0, ss: 0 }
273
+ : tzParts(startMs, occTz);
274
+ const startDay = dayNum(ref.y, ref.m, ref.d);
275
+ const untilMs = rule.until ? timeToMs(rule.until, timedTz, displayTz) : Infinity;
276
+ const windowEndDay = Math.ceil(win.windowEndMs / DAY) + 2;
277
+ const weekAnchor = startDay - dowOfDay(startDay);
278
+ const bydayDows = rule.byday.map((b) => b.dow);
279
+
280
+ // COUNT needs every occurrence counted from DTSTART; otherwise skip ahead
281
+ let day = rule.count ? startDay : Math.max(startDay, Math.floor(win.windowStartMs / DAY) - Math.max(35, spanDays + 1));
282
+ let produced = 0;
283
+ for (let steps = 0; day <= windowEndDay && steps < 40_000; steps++, day++) {
284
+ const { y, m, d } = fromDayNum(day);
285
+ let hit = false;
286
+ switch (rule.freq) {
287
+ case 'DAILY':
288
+ hit = (day - startDay) % rule.interval === 0;
289
+ break;
290
+ case 'WEEKLY': {
291
+ const inWeek = Math.floor((day - weekAnchor) / 7) % rule.interval === 0;
292
+ hit = inWeek && (bydayDows.length ? bydayDows.includes(dowOfDay(day)) : day === startDay || dowOfDay(day) === dowOfDay(startDay));
293
+ break;
294
+ }
295
+ case 'MONTHLY': {
296
+ const monthsSince = (y - ref.y) * 12 + (m - ref.m);
297
+ if (monthsSince % rule.interval !== 0) break;
298
+ if (rule.bymonthday.length) hit = rule.bymonthday.includes(d);
299
+ else if (rule.byday.length) {
300
+ const dow = dowOfDay(day);
301
+ hit = rule.byday.some((b) => {
302
+ if (b.dow !== dow) return false;
303
+ if (b.ord > 0) return Math.floor((d - 1) / 7) + 1 === b.ord;
304
+ if (b.ord < 0) return Math.floor((daysInMonth(y, m) - d) / 7) + 1 === -b.ord;
305
+ return true; // every matching weekday of the month
306
+ });
307
+ } else hit = d === ref.d;
308
+ break;
309
+ }
310
+ case 'YEARLY':
311
+ hit = m === ref.m && d === ref.d && (y - ref.y) % rule.interval === 0;
312
+ break;
313
+ }
314
+ if (!hit) continue;
315
+ if (day < startDay) continue;
316
+
317
+ const occStart = ev.start.dateOnly ? zonedToUtc(y, m, d, 0, 0, 0, displayTz) : zonedToUtc(y, m, d, ref.hh, ref.mm, ref.ss, occTz);
318
+ if (occStart > untilMs) break;
319
+ produced++;
320
+ if (rule.count && produced > rule.count) break;
321
+ if (exMs.has(occStart) || skipStarts.has(occStart)) continue;
322
+ push(occStart);
323
+ if (produced >= 1000) break;
324
+ }
325
+ }
326
+
327
+ export function expandVEvents(vevents: VEvent[], calTz: string, win: CalendarWindow, calIdx: number): CalendarEvent[] {
328
+ const displayTz = win.displayTz || 'UTC';
329
+ const timedTz = calTz || displayTz;
330
+ // RECURRENCE-ID overrides replace their master's generated instance
331
+ const skipByUid = new Map<string, Set<number>>();
332
+ for (const ev of vevents) {
333
+ if (!ev.recurrenceId || !ev.uid) continue;
334
+ const ms = timeToMs(ev.recurrenceId, timedTz, displayTz);
335
+ if (!skipByUid.has(ev.uid)) skipByUid.set(ev.uid, new Set());
336
+ skipByUid.get(ev.uid)!.add(ms);
337
+ }
338
+ const out: CalendarEvent[] = [];
339
+ const none = new Set<number>();
340
+ for (const ev of vevents) {
341
+ expandEvent(ev, calTz, win, calIdx, ev.recurrenceId ? none : skipByUid.get(ev.uid) ?? none, out);
342
+ }
343
+ return out;
344
+ }
345
+
346
+ /* ---------------- fetch + cache ---------------- */
347
+
348
+ interface CacheEntry { at: number; vevents: VEvent[]; calTz: string }
349
+ const feedCache = new Map<string, CacheEntry>();
350
+ const FEED_TTL = 5 * 60 * 1000;
351
+ const FEED_CACHE_MAX = 24;
352
+
353
+ async function loadFeed(url: string, now: number): Promise<CacheEntry> {
354
+ const cached = feedCache.get(url);
355
+ if (cached && now - cached.at < FEED_TTL) return cached;
356
+ const got = await fetchPublicUrl(url, {
357
+ accept: 'text/calendar, text/plain;q=0.8, */*;q=0.5',
358
+ userAgent: 'Mozilla/5.0 (compatible; acms-calendar/1.0)',
359
+ });
360
+ if (!got || !got.res.ok) {
361
+ if (cached) return cached; // stale beats broken
362
+ throw new Error(`Couldn’t load the calendar feed (${got?.res.status ?? 'unreachable'}).`);
363
+ }
364
+ const text = (await got.res.text()).slice(0, 2_000_000);
365
+ if (!text.includes('BEGIN:VCALENDAR')) {
366
+ if (cached) return cached;
367
+ throw new Error('That URL isn’t an ICS calendar feed.');
368
+ }
369
+ const entry: CacheEntry = { at: now, ...parseICS(text) };
370
+ feedCache.delete(url);
371
+ feedCache.set(url, entry);
372
+ if (feedCache.size > FEED_CACHE_MAX) {
373
+ const oldest = feedCache.keys().next().value;
374
+ if (oldest !== undefined) feedCache.delete(oldest);
375
+ }
376
+ return entry;
377
+ }
378
+
379
+ // Fetch + expand every source. Individual feed failures don't sink the widget:
380
+ // events from healthy sources still render and failures come back as messages.
381
+ export async function getCalendarEvents(
382
+ sources: string[],
383
+ win: CalendarWindow
384
+ ): Promise<{ events: CalendarEvent[]; errors: string[] }> {
385
+ const errors: string[] = [];
386
+ const perSource = await Promise.all(
387
+ sources.slice(0, 6).map(async (url, i) => {
388
+ try {
389
+ const feed = await loadFeed(url, win.now);
390
+ return expandVEvents(feed.vevents, feed.calTz, win, i);
391
+ } catch (err) {
392
+ errors.push(err instanceof Error ? err.message : 'Calendar feed failed.');
393
+ return [];
394
+ }
395
+ })
396
+ );
397
+ const events = perSource
398
+ .flat()
399
+ .sort((a, b) => a.start - b.start || a.title.localeCompare(b.title))
400
+ .slice(0, 800);
401
+ return { events, errors };
402
+ }
@@ -0,0 +1,275 @@
1
+ // Request-time HTML for the calendar card (data-dyn="calendar"), called from
2
+ // expandDynamicBlocks. Server-renders the card's chosen view — month grid,
3
+ // week strip, or upcoming list — so the widget works with JS disabled, then
4
+ // attaches a display-ready JSON payload + a small vanilla script for month/
5
+ // week navigation and the event popover. No framework code reaches the public
6
+ // site. Every element carries a stable .pc-cal-* class: that's the widget's
7
+ // public styling API (theme CSS and Settings → Code injection both restyle it).
8
+ import type { Settings } from '../schema';
9
+ import { siteLocale } from '../dates';
10
+ import { getCalendarEvents, tzParts, zonedToUtc, type CalendarEvent } from './ics';
11
+ import { calendarPageUrl } from './src';
12
+
13
+ export interface CalendarBlockAttrs {
14
+ sources: string[];
15
+ view: 'month' | 'week' | 'upcoming';
16
+ title: string;
17
+ subtitle: string;
18
+ limit: number;
19
+ showOpenLink: boolean;
20
+ }
21
+
22
+ const DAY = 86_400_000;
23
+
24
+ const esc = (s: string) =>
25
+ String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
26
+
27
+ const dayNum = (y: number, m: number, d: number) => Math.round(Date.UTC(y, m - 1, d) / DAY);
28
+ const fromDayNum = (n: number) => {
29
+ const dt = new Date(n * DAY);
30
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
31
+ };
32
+ const dowOfDay = (n: number) => (((n + 4) % 7) + 7) % 7;
33
+ const addMonths = (y: number, m: number, delta: number): [number, number] => {
34
+ const t = y * 12 + (m - 1) + delta;
35
+ return [Math.floor(t / 12), (((t % 12) + 12) % 12) + 1];
36
+ };
37
+
38
+ // one display-ready entry per event; the client script never touches timezones
39
+ interface DisplayEvent {
40
+ d: [number, number, number]; // local start day
41
+ n: number; // days covered
42
+ t: string; // title
43
+ tm: string; // chip time label ('' for all-day)
44
+ rg: string; // popover "when" line
45
+ l: string;
46
+ ds: string;
47
+ c: number; // source index (dot color)
48
+ }
49
+
50
+ function compactTime(s: string): string {
51
+ return s.replace(':00', '').replace(/\s?(AM|PM)/, (_, p: string) => p.toLowerCase());
52
+ }
53
+
54
+ function buildDisplay(events: CalendarEvent[], tz: string, locale: string): DisplayEvent[] {
55
+ const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
56
+ const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
57
+ return events.map((e) => {
58
+ const p = tzParts(e.start, tz);
59
+ const when = dayFmt.format(e.start);
60
+ let tm = '';
61
+ let rg: string;
62
+ if (e.allDay) {
63
+ rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · All day`;
64
+ } else {
65
+ tm = compactTime(timeFmt.format(e.start));
66
+ rg = `${when} · ${timeFmt.format(e.start)} – ${timeFmt.format(e.end)}`;
67
+ }
68
+ return {
69
+ d: [p.y, p.m, p.d], n: e.days, t: e.title, tm, rg,
70
+ l: e.location, ds: e.description.slice(0, 300), c: e.cal,
71
+ };
72
+ });
73
+ }
74
+
75
+ function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
76
+ const byDay = new Map<number, number[]>();
77
+ events.forEach((e, i) => {
78
+ const n0 = dayNum(e.d[0], e.d[1], e.d[2]);
79
+ for (let k = 0; k < Math.min(e.n, 60); k++) {
80
+ if (!byDay.has(n0 + k)) byDay.set(n0 + k, []);
81
+ byDay.get(n0 + k)!.push(i);
82
+ }
83
+ });
84
+ return byDay;
85
+ }
86
+
87
+ /* ---- view fragments (client script mirrors these exactly) ---- */
88
+
89
+ const chipHtml = (e: DisplayEvent, i: number) =>
90
+ `<button type="button" class="pc-cal-event" data-ev="${i}" data-cal="${e.c}"><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
91
+
92
+ function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[]): string {
93
+ const first = dayNum(y, m, 1);
94
+ const start = first - dowOfDay(first);
95
+ let h = dows.map((d) => `<div class="pc-cal-dow">${esc(d)}</div>`).join('');
96
+ for (let i = 0; i < 42; i++) {
97
+ const n = start + i;
98
+ const p = fromDayNum(n);
99
+ const out = p.m !== m;
100
+ const label = p.d === 1 ? `${esc(monthsShort[p.m - 1])} 1` : String(p.d);
101
+ const chips = out ? '' : (byDay.get(n) ?? []).map((idx) => chipHtml(events[idx], idx)).join('');
102
+ h += `<div class="pc-cal-day${out ? ' is-out' : ''}${n === todayN ? ' is-today' : ''}"><span class="pc-cal-date">${label}</span>${chips}</div>`;
103
+ }
104
+ return `<div class="pc-cal-grid">${h}</div>`;
105
+ }
106
+
107
+ function weekHtml(weekStart: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[]): string {
108
+ let h = '';
109
+ for (let i = 0; i < 7; i++) {
110
+ const n = weekStart + i;
111
+ const p = fromDayNum(n);
112
+ const chips = (byDay.get(n) ?? []).map((idx) => chipHtml(events[idx], idx)).join('');
113
+ h += `<div class="pc-cal-wday${n === todayN ? ' is-today' : ''}"><div class="whead"><span class="pc-cal-dow">${esc(dows[i])}</span><span class="pc-cal-date">${p.d}</span></div><div class="wevs">${chips}</div></div>`;
114
+ }
115
+ return `<div class="pc-cal-week">${h}</div>`;
116
+ }
117
+
118
+ function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): string {
119
+ if (!order.length) return `<p class="pc-cal-empty">No upcoming events.</p>`;
120
+ const items = order.slice(0, limit).map((i) => {
121
+ const e = events[i];
122
+ return `<li><button type="button" class="pc-cal-row" data-ev="${i}" data-cal="${e.c}"><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || 'All day'}</span></button></li>`;
123
+ });
124
+ return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
125
+ }
126
+
127
+ /* ---- the client script: nav + popover, ~1.6 KB minified-ish ---- */
128
+
129
+ const CLIENT_JS = `(function(){
130
+ var sc=document.currentScript,root=sc&&sc.closest('.pc-calendar');if(!root)return;
131
+ var P=JSON.parse(root.querySelector('script[type="application/json"]').textContent);
132
+ var body=root.querySelector('.pc-cal-body'),titleEl=root.querySelector('.pc-cal-title'),frame=root.querySelector('.pc-cal-frame');
133
+ var D=864e5;
134
+ function dnum(a){return Math.round(Date.UTC(a[0],a[1]-1,a[2])/D)}
135
+ function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1,d.getUTCDate()]}
136
+ function dow(n){return((n+4)%7+7)%7}
137
+ var byDay={};P.events.forEach(function(e,i){var n0=dnum(e.d);for(var k=0;k<Math.min(e.n,60);k++){(byDay[n0+k]=byDay[n0+k]||[]).push(i)}});
138
+ function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
139
+ function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event" data-ev="'+i+'" data-cal="'+e.c+'"><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
140
+ function chips(n){return(byDay[n]||[]).map(chip).join('')}
141
+ var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
142
+ function renderMonth(){
143
+ titleEl.textContent=P.months[mM-1]+' '+mY;
144
+ var first=dnum([mY,mM,1]),start=first-dow(first),h='',i,n,p;
145
+ for(i=0;i<7;i++)h+='<div class="pc-cal-dow">'+esc(P.dows[i])+'</div>';
146
+ for(i=0;i<42;i++){n=start+i;p=fday(n);var out=p[1]!==mM;
147
+ h+='<div class="pc-cal-day'+(out?' is-out':'')+(n===todayN?' is-today':'')+'"><span class="pc-cal-date">'+(p[2]===1?esc(P.monthsShort[p[1]-1])+' 1':p[2])+'</span>'+(out?'':chips(n))+'</div>'}
148
+ body.innerHTML='<div class="pc-cal-grid">'+h+'</div>'}
149
+ function renderWeek(){
150
+ var a=fday(weekN),b=fday(weekN+6);
151
+ titleEl.textContent=P.monthsShort[a[1]-1]+' '+a[2]+' – '+(a[1]===b[1]?'':P.monthsShort[b[1]-1]+' ')+b[2]+', '+b[0];
152
+ var h='',i,n,p;
153
+ for(i=0;i<7;i++){n=weekN+i;p=fday(n);
154
+ h+='<div class="pc-cal-wday'+(n===todayN?' is-today':'')+'"><div class="whead"><span class="pc-cal-dow">'+esc(P.dows[i])+'</span><span class="pc-cal-date">'+p[2]+'</span></div><div class="wevs">'+chips(n)+'</div></div>'}
155
+ body.innerHTML='<div class="pc-cal-week">'+h+'</div>'}
156
+ function rerender(){if(P.view==='month')renderMonth();else if(P.view==='week')renderWeek()}
157
+ function inRange(y,m){var v=y*12+m;return v>=P.min[0]*12+P.min[1]&&v<=P.max[0]*12+P.max[1]}
158
+ function nav(dir){
159
+ if(P.view==='month'){var next=mM+dir,y=mY;if(next<1){next=12;y--}if(next>12){next=1;y++}
160
+ if(!inRange(y,next))return;mY=y;mM=next}
161
+ else{var n=weekN+dir*7,p=fday(n);if(!inRange(p[0],p[1]))return;weekN=n}
162
+ closePop();rerender()}
163
+ var bp=root.querySelector('[data-nav="prev"]'),bn=root.querySelector('[data-nav="next"]'),bt=root.querySelector('[data-nav="today"]');
164
+ if(bp){bp.addEventListener('click',function(){nav(-1)});bn.addEventListener('click',function(){nav(1)});
165
+ bt.addEventListener('click',function(){closePop();mY=P.today[0];mM=P.today[1];weekN=todayN-dow(todayN);rerender()})}
166
+ var pop=null;
167
+ function closePop(){if(pop){pop.remove();pop=null}}
168
+ root.addEventListener('click',function(ev){
169
+ var t=ev.target;if(!t.closest)return;
170
+ var hit=t.closest('[data-ev]');
171
+ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
172
+ var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
173
+ closePop();
174
+ pop=document.createElement('div');pop.className='pc-cal-pop';
175
+ pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="Close">&times;</button><strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'');
176
+ frame.appendChild(pop);
177
+ var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
178
+ pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
179
+ pop.style.top=(cr.bottom-fr.top+6)+'px';
180
+ pop.querySelector('.pc-cal-pop-x').addEventListener('click',closePop)});
181
+ document.addEventListener('keydown',function(e){if(e.key==='Escape')closePop()});
182
+ document.addEventListener('click',function(e){if(pop&&e.target instanceof Node&&!root.contains(e.target))closePop()});
183
+ })();`;
184
+
185
+ /* ---- entry point ---- */
186
+
187
+ export async function renderCalendarBlock(a: CalendarBlockAttrs, settings: Settings, now: number): Promise<string> {
188
+ const heading =
189
+ (a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
190
+ (a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
191
+
192
+ if (!a.sources.length) {
193
+ return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">No calendar connected.</p></div></section>`;
194
+ }
195
+
196
+ const tz = settings.timezone || 'UTC';
197
+ const locale = siteLocale(settings);
198
+ const today = tzParts(now, tz);
199
+ const [minY, minM] = addMonths(today.y, today.m, -6);
200
+ const [maxY, maxM] = addMonths(today.y, today.m, 6);
201
+ const [endY, endM] = addMonths(today.y, today.m, 7);
202
+ const win = {
203
+ displayTz: tz, now,
204
+ windowStartMs: zonedToUtc(minY, minM, 1, 0, 0, 0, tz),
205
+ windowEndMs: zonedToUtc(endY, endM, 1, 0, 0, 0, tz),
206
+ };
207
+ const { events: raw, errors } = await getCalendarEvents(a.sources, win);
208
+
209
+ if (!raw.length && errors.length === a.sources.length) {
210
+ return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">Calendar unavailable right now.</p></div></section>`;
211
+ }
212
+
213
+ const events = buildDisplay(raw, tz, locale);
214
+ const byDay = indexByDay(events);
215
+ const todayN = dayNum(today.y, today.m, today.d);
216
+
217
+ const utcLabel = (opts: Intl.DateTimeFormatOptions, ms: number) =>
218
+ new Intl.DateTimeFormat(locale, { ...opts, timeZone: 'UTC' }).format(ms);
219
+ const months = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'long' }, Date.UTC(2026, i, 1)));
220
+ const monthsShort = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'short' }, Date.UTC(2026, i, 1)));
221
+ const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + i))); // Aug 2 2026 = Sunday
222
+
223
+ let bodyHtml: string;
224
+ let titleText: string;
225
+ if (a.view === 'week') {
226
+ const weekStart = todayN - dowOfDay(todayN);
227
+ const wa = fromDayNum(weekStart);
228
+ const wb = fromDayNum(weekStart + 6);
229
+ titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
230
+ bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
231
+ } else if (a.view === 'upcoming') {
232
+ titleText = 'Upcoming';
233
+ const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
234
+ const order = raw.map((e, i) => ({ e, i })).filter(({ e }) => e.end > startOfToday).map(({ i }) => i);
235
+ bodyHtml = upcomingHtml(events, order, Math.min(20, Math.max(1, a.limit || 6)));
236
+ } else {
237
+ titleText = `${months[today.m - 1]} ${today.y}`;
238
+ bodyHtml = monthGridHtml(today.y, today.m, todayN, byDay, events, dows, monthsShort);
239
+ }
240
+
241
+ const navButtons =
242
+ a.view === 'upcoming'
243
+ ? ''
244
+ : `<button type="button" class="pc-cal-btn" data-nav="today">Today</button>` +
245
+ `<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="Previous">‹</button>` +
246
+ `<button type="button" class="pc-cal-btn" data-nav="next" aria-label="Next">›</button>`;
247
+
248
+ const pageUrl = a.showOpenLink ? a.sources.map(calendarPageUrl).find(Boolean) ?? '' : '';
249
+ const footer = pageUrl
250
+ ? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(pageUrl)}" target="_blank" rel="noopener">Open in Google Calendar ↗</a></div>`
251
+ : '';
252
+
253
+ const payload = {
254
+ view: a.view,
255
+ today: [today.y, today.m, today.d],
256
+ month: [today.y, today.m],
257
+ min: [minY, minM],
258
+ max: [maxY, maxM],
259
+ months, monthsShort, dows, events,
260
+ };
261
+ const json = JSON.stringify(payload).replace(/</g, '\\u003c');
262
+
263
+ const multi = a.sources.length > 1 ? ' is-multi' : ''; // dots only mean something with 2+ calendars
264
+ return (
265
+ `<section class="pc-calendar pc-cal-view-${a.view}${multi}">${heading}` +
266
+ `<div class="pc-cal-frame">` +
267
+ `<div class="pc-cal-toolbar"><span class="pc-cal-title">${esc(titleText)}</span>${navButtons}</div>` +
268
+ `<div class="pc-cal-body">${bodyHtml}</div>` +
269
+ footer +
270
+ `</div>` +
271
+ `<script type="application/json">${json}</script>` +
272
+ `<script>${CLIENT_JS}</script>` +
273
+ `</section>`
274
+ );
275
+ }