@acms-subir/core 0.1.0 → 0.3.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.
Files changed (237) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -315
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -0,0 +1,120 @@
1
+ // ICS output: subscribe feeds (/calendar.ics, /calendar/<slug>.ics) and the
2
+ // single-event download behind "Add to calendar → Apple". One-off timed
3
+ // events are written in UTC (unambiguous everywhere); recurring timed events
4
+ // keep their wall clock with TZID=<IANA zone> so a weekly 10am stays at 10am
5
+ // across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
6
+ // events are VALUE=DATE with the RFC's exclusive DTEND.
7
+ import type { CalEvent, Calendar } from '../schema';
8
+ import { addWallDays, eventInstants, eventTimeZone, locationLine, parseWall, type Wall } from './events';
9
+ import { zonedToUtc } from './ics';
10
+ import { cleanRrule, rruleMap } from './rrule';
11
+
12
+ const pad = (n: number) => String(n).padStart(2, '0');
13
+
14
+ export function escapeText(s: string): string {
15
+ return s.replace(/\\/g, '\\\\').replace(/;/g, '\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
16
+ }
17
+
18
+ // RFC 5545 §3.1: lines longer than 75 octets fold with CRLF + space. Folding
19
+ // counts UTF-8 bytes and never splits a multi-byte character.
20
+ export function foldLine(line: string): string {
21
+ const enc = new TextEncoder();
22
+ if (enc.encode(line).length <= 75) return line;
23
+ const out: string[] = [];
24
+ let cur = '';
25
+ let bytes = 0;
26
+ for (const ch of line) {
27
+ const n = enc.encode(ch).length;
28
+ const limit = out.length === 0 ? 75 : 74; // continuation lines carry the leading space
29
+ if (bytes + n > limit) {
30
+ out.push(cur);
31
+ cur = '';
32
+ bytes = 0;
33
+ }
34
+ cur += ch;
35
+ bytes += n;
36
+ }
37
+ out.push(cur);
38
+ return out.join('\r\n ');
39
+ }
40
+
41
+ const utcStamp = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
42
+ const dateValue = (w: Wall) => `${w.y}${pad(w.m)}${pad(w.d)}`;
43
+ const localValue = (w: Wall) => `${dateValue(w)}T${pad(w.hh)}${pad(w.mm)}00`;
44
+
45
+ // timed events: a date-only UNTIL ("through Oct 30") becomes the UTC instant
46
+ // of that day's end in the event zone, as RFC 5545 requires for DATE-TIME
47
+ // starts
48
+ function feedRrule(rrule: string, allDay: boolean, tz: string): string {
49
+ const clean = cleanRrule(rrule);
50
+ if (!clean) return '';
51
+ const parts = rruleMap(clean);
52
+ if (!allDay && parts.UNTIL && /^\d{8}$/.test(parts.UNTIL)) {
53
+ const y = +parts.UNTIL.slice(0, 4), m = +parts.UNTIL.slice(4, 6), d = +parts.UNTIL.slice(6, 8);
54
+ parts.UNTIL = utcStamp(zonedToUtc(y, m, d, 23, 59, 59, tz));
55
+ }
56
+ return Object.entries(parts).map(([k, v]) => `${k}=${v}`).join(';');
57
+ }
58
+
59
+ export interface FeedOptions {
60
+ name: string;
61
+ description?: string;
62
+ origin: string; // absolute site origin for URL + UID
63
+ host: string;
64
+ tz: string; // site zone, for X-WR-TIMEZONE
65
+ eventUrl: (e: CalEvent) => string;
66
+ calendars?: Map<string, Calendar>;
67
+ refreshMinutes?: number;
68
+ now?: number;
69
+ }
70
+
71
+ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): string[] {
72
+ const s = parseWall(e.start);
73
+ if (!s) return [];
74
+ const en = parseWall(e.end) ?? s;
75
+ const tz = eventTimeZone(e, { timezone: siteTz });
76
+ const rule = feedRrule(e.rrule, e.allDay, tz);
77
+ const lines: string[] = ['BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`];
78
+ if (e.allDay) {
79
+ lines.push(`DTSTART;VALUE=DATE:${dateValue(s)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...en, dateOnly: true }, 1))}`);
80
+ } else if (rule) {
81
+ lines.push(`DTSTART;TZID=${tz}:${localValue(s)}`, `DTEND;TZID=${tz}:${localValue(en)}`);
82
+ } else {
83
+ const { startAt, endAt } = eventInstants(e, tz);
84
+ lines.push(`DTSTART:${utcStamp(startAt)}`, `DTEND:${utcStamp(endAt)}`);
85
+ }
86
+ if (rule) lines.push(`RRULE:${rule}`);
87
+ const ex = e.exdates.map(parseWall).filter((w): w is Wall => w !== null);
88
+ if (ex.length) {
89
+ lines.push(e.allDay ? `EXDATE;VALUE=DATE:${ex.map(dateValue).join(',')}` : `EXDATE;TZID=${tz}:${ex.map(localValue).join(',')}`);
90
+ }
91
+ const url = opts.origin + opts.eventUrl(e);
92
+ lines.push(`SUMMARY:${escapeText(e.title || 'Untitled event')}`);
93
+ const loc = locationLine(e.location);
94
+ if (loc) lines.push(`LOCATION:${escapeText(loc)}`);
95
+ const desc = [e.excerpt, e.location.url && e.location.url !== loc ? e.location.url : '', url].filter(Boolean).join('\n\n');
96
+ lines.push(`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`);
97
+ const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
98
+ if (cats.length) lines.push(`CATEGORIES:${cats.map(escapeText).join(',')}`);
99
+ if (e.cancelled) lines.push('STATUS:CANCELLED');
100
+ lines.push(`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`, 'END:VEVENT');
101
+ return lines;
102
+ }
103
+
104
+ export function buildFeed(events: CalEvent[], siteTz: string, opts: FeedOptions): string {
105
+ const lines = [
106
+ 'BEGIN:VCALENDAR',
107
+ 'VERSION:2.0',
108
+ 'PRODID:-//acms-subir//Calendar//EN',
109
+ 'CALSCALE:GREGORIAN',
110
+ 'METHOD:PUBLISH',
111
+ `X-WR-CALNAME:${escapeText(opts.name)}`,
112
+ ...(opts.description ? [`X-WR-CALDESC:${escapeText(opts.description)}`] : []),
113
+ `X-WR-TIMEZONE:${siteTz || 'UTC'}`,
114
+ `REFRESH-INTERVAL;VALUE=DURATION:PT${opts.refreshMinutes ?? 60}M`,
115
+ `X-PUBLISHED-TTL:PT${opts.refreshMinutes ?? 60}M`,
116
+ ...events.flatMap((e) => eventToVevent(e, siteTz, opts)),
117
+ 'END:VCALENDAR',
118
+ ];
119
+ return lines.map(foldLine).join('\r\n') + '\r\n';
120
+ }
@@ -17,6 +17,8 @@ export interface CalendarEvent {
17
17
  location: string;
18
18
  description: string;
19
19
  cal: number; // index into the card's sources list (drives the dot color)
20
+ // native events only: the event id, so views can link to the event page
21
+ ref?: string;
20
22
  }
21
23
 
22
24
  export interface CalendarWindow {
@@ -26,15 +28,17 @@ export interface CalendarWindow {
26
28
  now: number;
27
29
  }
28
30
 
29
- interface IcsTime {
31
+ export interface IcsTime {
30
32
  y: number; m: number; d: number; hh: number; mm: number; ss: number;
31
33
  tz: string | null;
32
34
  dateOnly: boolean;
33
35
  utc: boolean;
34
36
  }
35
37
 
36
- interface VEvent {
38
+ export interface VEvent {
37
39
  uid: string;
40
+ // native events: the source event id, carried onto every occurrence
41
+ ref?: string;
38
42
  title: string;
39
43
  location: string;
40
44
  description: string;
@@ -254,7 +258,7 @@ function expandEvent(
254
258
  const push = (s: number) => {
255
259
  const e = allDay ? s + spanDays * DAY : s + durMs;
256
260
  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 });
261
+ out.push({ start: s, end: e, allDay, days: spanDays, title: ev.title || '(untitled)', location: ev.location, description: ev.description, cal: calIdx, ...(ev.ref ? { ref: ev.ref } : {}) });
258
262
  };
259
263
 
260
264
  const rule = ev.rrule && !ev.recurrenceId ? parseRRule(ev.rrule) : null;
@@ -272,7 +276,13 @@ function expandEvent(
272
276
  ? { y: ev.start.y, m: ev.start.m, d: ev.start.d, hh: 0, mm: 0, ss: 0 }
273
277
  : tzParts(startMs, occTz);
274
278
  const startDay = dayNum(ref.y, ref.m, ref.d);
275
- const untilMs = rule.until ? timeToMs(rule.until, timedTz, displayTz) : Infinity;
279
+ // a date-only UNTIL covers that whole day (Google and Apple read it the
280
+ // same way); a date-time UNTIL is the exact last allowed start
281
+ const untilMs = !rule.until
282
+ ? Infinity
283
+ : rule.until.dateOnly && !ev.start.dateOnly
284
+ ? zonedToUtc(rule.until.y, rule.until.m, rule.until.d, 23, 59, 59, occTz)
285
+ : timeToMs(rule.until, timedTz, displayTz);
276
286
  const windowEndDay = Math.ceil(win.windowEndMs / DAY) + 2;
277
287
  const weekAnchor = startDay - dowOfDay(startDay);
278
288
  const bydayDows = rule.byday.map((b) => b.dow);
@@ -350,7 +360,7 @@ const feedCache = new Map<string, CacheEntry>();
350
360
  const FEED_TTL = 5 * 60 * 1000;
351
361
  const FEED_CACHE_MAX = 24;
352
362
 
353
- async function loadFeed(url: string, now: number): Promise<CacheEntry> {
363
+ export async function loadFeed(url: string, now: number): Promise<{ at: number; vevents: VEvent[]; calTz: string }> {
354
364
  const cached = feedCache.get(url);
355
365
  if (cached && now - cached.at < FEED_TTL) return cached;
356
366
  const got = await fetchPublicUrl(url, {
@@ -0,0 +1,203 @@
1
+ // Bring events in from elsewhere: an ICS file/feed (Google, Apple, Outlook
2
+ // exports) or a spreadsheet CSV. Both produce plain event inputs that the
3
+ // import action saves as DRAFTS — nothing goes live without a review.
4
+ // Recurring ICS events keep their RRULE/EXDATEs; per-instance overrides
5
+ // (RECURRENCE-ID) are skipped, since a native event can't hold them yet.
6
+ import type { EventLocation } from '../schema';
7
+ import { parseICS, tzParts, type IcsTime, type VEvent } from './ics';
8
+ import { isValidTimeZone, normalizeEventTimes, parseWall, addWallDays, formatWall } from './events';
9
+ import { cleanRrule } from './rrule';
10
+
11
+ export interface ImportedEvent {
12
+ title: string;
13
+ start: string;
14
+ end: string;
15
+ allDay: boolean;
16
+ timezone: string;
17
+ rrule: string;
18
+ exdates: string[];
19
+ excerpt: string;
20
+ bodyHtml: string;
21
+ location: EventLocation;
22
+ cancelled: boolean;
23
+ sourceUid: string;
24
+ }
25
+
26
+ const pad = (n: number) => String(n).padStart(2, '0');
27
+ const escHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
28
+ const paragraphs = (text: string) =>
29
+ text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${escHtml(p).replace(/\n/g, '<br>')}</p>`).join('');
30
+
31
+ // ICS time → wall clock in `zone`. Floating and TZID times keep their wall
32
+ // clock (the TZID becomes the event zone when we know it); UTC times are
33
+ // converted into the site zone.
34
+ function wallOf(t: IcsTime, siteTz: string): { wall: string; zone: string } {
35
+ if (t.dateOnly) return { wall: `${t.y}-${pad(t.m)}-${pad(t.d)}`, zone: '' };
36
+ if (t.utc) {
37
+ const p = tzParts(Date.UTC(t.y, t.m - 1, t.d, t.hh, t.mm, t.ss), siteTz || 'UTC');
38
+ return { wall: `${p.y}-${pad(p.m)}-${pad(p.d)}T${pad(p.hh)}:${pad(p.mm)}`, zone: '' };
39
+ }
40
+ const zone = t.tz && isValidTimeZone(t.tz) && t.tz !== siteTz ? t.tz : '';
41
+ return { wall: `${t.y}-${pad(t.m)}-${pad(t.d)}T${pad(t.hh)}:${pad(t.mm)}`, zone };
42
+ }
43
+
44
+ export function eventsFromIcs(text: string, siteTz: string): { events: ImportedEvent[]; skipped: number } {
45
+ const { vevents, calTz } = parseICS(text);
46
+ return eventsFromVEvents(vevents, calTz, siteTz);
47
+ }
48
+
49
+ export function eventsFromVEvents(vevents: VEvent[], calTz: string, siteTz: string): { events: ImportedEvent[]; skipped: number } {
50
+ const out: ImportedEvent[] = [];
51
+ let skipped = 0;
52
+ for (const v of vevents) {
53
+ if (v.recurrenceId) { skipped++; continue; }
54
+ const s = wallOf(v.start, siteTz);
55
+ // floating times in a feed with X-WR-TIMEZONE belong to that zone
56
+ const zone = s.zone || (!v.start.dateOnly && !v.start.utc && !v.start.tz && calTz && isValidTimeZone(calTz) && calTz !== siteTz ? calTz : '');
57
+ let end = '';
58
+ if (v.end) {
59
+ if (v.start.dateOnly) {
60
+ const w = parseWall(wallOf(v.end, siteTz).wall);
61
+ end = w ? formatWall(addWallDays({ ...w, dateOnly: true }, -1)) : '';
62
+ } else end = wallOf(v.end, siteTz).wall;
63
+ }
64
+ let times: { start: string; end: string };
65
+ try {
66
+ times = normalizeEventTimes({ start: s.wall, end, allDay: v.start.dateOnly });
67
+ } catch {
68
+ skipped++;
69
+ continue;
70
+ }
71
+ out.push({
72
+ title: (v.title || 'Untitled event').slice(0, 300),
73
+ start: times.start,
74
+ end: times.end,
75
+ allDay: v.start.dateOnly,
76
+ timezone: zone,
77
+ rrule: cleanRrule(v.rrule),
78
+ exdates: v.exdates.map((t) => wallOf(t, siteTz).wall),
79
+ excerpt: v.description.slice(0, 300),
80
+ bodyHtml: paragraphs(v.description),
81
+ location: { name: v.location.slice(0, 300), address: '', url: '' },
82
+ cancelled: v.cancelled,
83
+ sourceUid: v.uid,
84
+ });
85
+ }
86
+ return { events: out.slice(0, 500), skipped: skipped + Math.max(0, out.length - 500) };
87
+ }
88
+
89
+ // ---- CSV -------------------------------------------------------------------
90
+
91
+ export function parseCsv(text: string): string[][] {
92
+ const rows: string[][] = [];
93
+ let row: string[] = [];
94
+ let cell = '';
95
+ let q = false;
96
+ const src = text.replace(/^/, '');
97
+ for (let i = 0; i < src.length; i++) {
98
+ const c = src[i];
99
+ if (q) {
100
+ if (c === '"' && src[i + 1] === '"') { cell += '"'; i++; }
101
+ else if (c === '"') q = false;
102
+ else cell += c;
103
+ continue;
104
+ }
105
+ if (c === '"') q = true;
106
+ else if (c === ',') { row.push(cell); cell = ''; }
107
+ else if (c === '\n' || c === '\r') {
108
+ if (c === '\r' && src[i + 1] === '\n') i++;
109
+ row.push(cell); cell = '';
110
+ if (row.some((x) => x.trim())) rows.push(row);
111
+ row = [];
112
+ } else cell += c;
113
+ }
114
+ row.push(cell);
115
+ if (row.some((x) => x.trim())) rows.push(row);
116
+ return rows;
117
+ }
118
+
119
+ // 2026-10-03 · 10/3/2026 · 3 Oct 2026 → YYYY-MM-DD
120
+ function csvDate(raw: string): string {
121
+ const s = raw.trim();
122
+ let m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(s);
123
+ if (m) return `${m[1]}-${pad(+m[2])}-${pad(+m[3])}`;
124
+ m = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/.exec(s);
125
+ if (m) return `${m[3].length === 2 ? `20${m[3]}` : m[3]}-${pad(+m[1])}-${pad(+m[2])}`;
126
+ const t = Date.parse(s);
127
+ if (!Number.isNaN(t)) {
128
+ const d = new Date(t);
129
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
130
+ }
131
+ return '';
132
+ }
133
+
134
+ // 19:00 · 7pm · 7:30 PM → HH:mm
135
+ function csvTime(raw: string): string {
136
+ const m = /^(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?$/i.exec(raw.trim());
137
+ if (!m) return '';
138
+ let h = +m[1];
139
+ const min = m[2] ? +m[2] : 0;
140
+ if (m[3]) {
141
+ const pm = m[3].toLowerCase().startsWith('p');
142
+ if (h === 12) h = pm ? 12 : 0;
143
+ else if (pm) h += 12;
144
+ }
145
+ return h <= 23 && min <= 59 ? `${pad(h)}:${pad(min)}` : '';
146
+ }
147
+
148
+ const HEADERS: Record<string, string[]> = {
149
+ title: ['title', 'name', 'summary', 'event', 'subject'],
150
+ startDate: ['start date', 'start', 'date', 'starts', 'start_date'],
151
+ startTime: ['start time', 'time', 'start_time'],
152
+ endDate: ['end date', 'end', 'ends', 'end_date'],
153
+ endTime: ['end time', 'end_time'],
154
+ allDay: ['all day', 'all-day', 'allday', 'all day event'],
155
+ location: ['location', 'venue', 'place', 'where'],
156
+ address: ['address'],
157
+ description: ['description', 'details', 'notes', 'about'],
158
+ url: ['url', 'link', 'website'],
159
+ };
160
+
161
+ export function eventsFromCsv(text: string): { events: ImportedEvent[]; skipped: number; error?: string } {
162
+ const rows = parseCsv(text);
163
+ if (rows.length < 2) return { events: [], skipped: 0, error: 'The CSV needs a header row and at least one event.' };
164
+ const head = rows[0].map((h) => h.trim().toLowerCase());
165
+ const col = (key: string) => head.findIndex((h) => HEADERS[key].includes(h));
166
+ const idx = Object.fromEntries(Object.keys(HEADERS).map((k) => [k, col(k)])) as Record<string, number>;
167
+ if (idx.title < 0 || idx.startDate < 0) {
168
+ return { events: [], skipped: 0, error: 'The CSV needs at least a "Title" and a "Start date" column.' };
169
+ }
170
+ const get = (r: string[], k: string) => (idx[k] >= 0 ? (r[idx[k]] ?? '').trim() : '');
171
+ const events: ImportedEvent[] = [];
172
+ let skipped = 0;
173
+ for (const r of rows.slice(1, 501)) {
174
+ const title = get(r, 'title');
175
+ // "Start" columns sometimes hold a full datetime: split it
176
+ const rawStart = get(r, 'startDate');
177
+ const split = /^(.+?)[ T](\d{1,2}(?::\d{2})?\s*(?:[ap]\.?m\.?)?)$/i.exec(rawStart);
178
+ const date = csvDate(split ? split[1] : rawStart);
179
+ const time = csvTime(get(r, 'startTime') || (split ? split[2] : ''));
180
+ if (!title || !date) { skipped++; continue; }
181
+ const allDayCell = get(r, 'allDay').toLowerCase();
182
+ const allDay = ['true', 'yes', 'y', '1', 'x'].includes(allDayCell) || !time;
183
+ const endDate = csvDate(get(r, 'endDate')) || date;
184
+ const endTime = csvTime(get(r, 'endTime'));
185
+ try {
186
+ const times = normalizeEventTimes({
187
+ start: allDay ? date : `${date}T${time}`,
188
+ end: allDay ? endDate : endTime ? `${endDate}T${endTime}` : '',
189
+ allDay,
190
+ });
191
+ const desc = get(r, 'description');
192
+ events.push({
193
+ title: title.slice(0, 300), ...times, allDay, timezone: '', rrule: '', exdates: [],
194
+ excerpt: desc.slice(0, 300), bodyHtml: paragraphs(desc),
195
+ location: { name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) },
196
+ cancelled: false, sourceUid: '',
197
+ });
198
+ } catch {
199
+ skipped++;
200
+ }
201
+ }
202
+ return { events, skipped: skipped + Math.max(0, rows.length - 501) };
203
+ }
@@ -6,9 +6,18 @@
6
6
  // site. Every element carries a stable .pc-cal-* class: that's the widget's
7
7
  // public styling API (theme CSS and Settings → Code injection both restyle it).
8
8
  import type { Settings } from '../schema';
9
+ import type { ContentRepository } from '../repo/types';
9
10
  import { siteLocale } from '../dates';
10
- import { getCalendarEvents, tzParts, zonedToUtc, type CalendarEvent } from './ics';
11
+ import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
11
12
  import { calendarPageUrl } from './src';
13
+ import { collectViewEvents, type ViewEvent } from './data';
14
+
15
+ // Card sources are ICS feed URLs or native site calendars:
16
+ // 'acms:all' every public calendar (+ uncategorized events)
17
+ // 'acms:cal:<slug>' one site calendar (public or unlisted)
18
+ export const NATIVE_ALL = 'acms:all';
19
+ export const nativeCalendarSource = (slug: string) => `acms:cal:${slug}`;
20
+ export const isNativeSource = (s: string) => s === NATIVE_ALL || /^acms:cal:[a-z0-9-]+$/.test(s);
12
21
 
13
22
  export interface CalendarBlockAttrs {
14
23
  sources: string[];
@@ -45,13 +54,16 @@ interface DisplayEvent {
45
54
  l: string;
46
55
  ds: string;
47
56
  c: number; // source index (dot color)
57
+ u?: string; // native events: the event page
58
+ k?: string; // calendar color (#rrggbb), overrides the indexed dot color
59
+ x?: 1; // cancelled
48
60
  }
49
61
 
50
62
  function compactTime(s: string): string {
51
63
  return s.replace(':00', '').replace(/\s?(AM|PM)/, (_, p: string) => p.toLowerCase());
52
64
  }
53
65
 
54
- function buildDisplay(events: CalendarEvent[], tz: string, locale: string): DisplayEvent[] {
66
+ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
55
67
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
56
68
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
57
69
  return events.map((e) => {
@@ -68,6 +80,9 @@ function buildDisplay(events: CalendarEvent[], tz: string, locale: string): Disp
68
80
  return {
69
81
  d: [p.y, p.m, p.d], n: e.days, t: e.title, tm, rg,
70
82
  l: e.location, ds: e.description.slice(0, 300), c: e.cal,
83
+ ...(e.url ? { u: e.url } : {}),
84
+ ...(e.color && /^#[0-9a-f]{6}$/i.test(e.color) ? { k: e.color } : {}),
85
+ ...(e.cancelled ? { x: 1 as const } : {}),
71
86
  };
72
87
  });
73
88
  }
@@ -86,8 +101,9 @@ function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
86
101
 
87
102
  /* ---- view fragments (client script mirrors these exactly) ---- */
88
103
 
104
+ const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
89
105
  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>`;
106
+ `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
91
107
 
92
108
  function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[]): string {
93
109
  const first = dayNum(y, m, 1);
@@ -119,7 +135,7 @@ function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): s
119
135
  if (!order.length) return `<p class="pc-cal-empty">No upcoming events.</p>`;
120
136
  const items = order.slice(0, limit).map((i) => {
121
137
  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>`;
138
+ return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><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
139
  });
124
140
  return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
125
141
  }
@@ -136,7 +152,7 @@ function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1
136
152
  function dow(n){return((n+4)%7+7)%7}
137
153
  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
154
  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>'}
155
+ function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
140
156
  function chips(n){return(byDay[n]||[]).map(chip).join('')}
141
157
  var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
142
158
  function renderMonth(){
@@ -172,7 +188,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
172
188
  var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
173
189
  closePop();
174
190
  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>':'');
191
+ pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="Close">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<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>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
176
192
  frame.appendChild(pop);
177
193
  var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
178
194
  pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
@@ -182,87 +198,103 @@ document.addEventListener('keydown',function(e){if(e.key==='Escape')closePop()})
182
198
  document.addEventListener('click',function(e){if(pop&&e.target instanceof Node&&!root.contains(e.target))closePop()});
183
199
  })();`;
184
200
 
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>` : '');
201
+ /* ---- entry points ---- */
191
202
 
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
- }
203
+ export interface CalendarViewOptions {
204
+ view: 'month' | 'week' | 'upcoming';
205
+ title?: string;
206
+ subtitle?: string;
207
+ limit?: number;
208
+ // quiet footer link (the ICS card's "Open in Google Calendar ↗")
209
+ footer?: { href: string; label: string };
210
+ // show per-calendar dots (only meaningful with 2+ calendars)
211
+ multi?: boolean;
212
+ emptyText?: string;
213
+ // month/week views open on this local month instead of today's
214
+ focus?: { y: number; m: number };
215
+ }
195
216
 
217
+ export function calendarWindow(settings: Pick<Settings, 'timezone'>, now: number) {
196
218
  const tz = settings.timezone || 'UTC';
197
- const locale = siteLocale(settings);
198
219
  const today = tzParts(now, tz);
199
220
  const [minY, minM] = addMonths(today.y, today.m, -6);
200
- const [maxY, maxM] = addMonths(today.y, today.m, 6);
201
221
  const [endY, endM] = addMonths(today.y, today.m, 7);
202
- const win = {
222
+ return {
203
223
  displayTz: tz, now,
204
224
  windowStartMs: zonedToUtc(minY, minM, 1, 0, 0, 0, tz),
205
225
  windowEndMs: zonedToUtc(endY, endM, 1, 0, 0, 0, tz),
206
226
  };
207
- const { events: raw, errors } = await getCalendarEvents(a.sources, win);
227
+ }
208
228
 
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
- }
229
+ // Pure renderer: occurrences in, markup out. Used by the calendar card, the
230
+ // /calendar page and embeds, so all three share one look and one script.
231
+ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: number, opts: CalendarViewOptions): string {
232
+ const heading =
233
+ (opts.title ? `<h3 class="pc-cal-heading">${esc(opts.title)}</h3>` : '') +
234
+ (opts.subtitle ? `<p class="pc-cal-sub">${esc(opts.subtitle)}</p>` : '');
235
+ const tz = settings.timezone || 'UTC';
236
+ const locale = siteLocale(settings);
237
+ const today = tzParts(now, tz);
238
+ const [minY, minM] = addMonths(today.y, today.m, -6);
239
+ const [maxY, maxM] = addMonths(today.y, today.m, 6);
212
240
 
213
241
  const events = buildDisplay(raw, tz, locale);
214
242
  const byDay = indexByDay(events);
215
243
  const todayN = dayNum(today.y, today.m, today.d);
216
244
 
217
- const utcLabel = (opts: Intl.DateTimeFormatOptions, ms: number) =>
218
- new Intl.DateTimeFormat(locale, { ...opts, timeZone: 'UTC' }).format(ms);
245
+ const utcLabel = (o: Intl.DateTimeFormatOptions, ms: number) =>
246
+ new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(ms);
219
247
  const months = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'long' }, Date.UTC(2026, i, 1)));
220
248
  const monthsShort = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'short' }, Date.UTC(2026, i, 1)));
221
249
  const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + i))); // Aug 2 2026 = Sunday
222
250
 
251
+ const focusOk = opts.focus && (opts.focus.y * 12 + opts.focus.m) >= minY * 12 + minM && (opts.focus.y * 12 + opts.focus.m) <= maxY * 12 + maxM;
252
+ const [fy, fm] = focusOk ? [opts.focus!.y, opts.focus!.m] : [today.y, today.m];
253
+
223
254
  let bodyHtml: string;
224
255
  let titleText: string;
225
- if (a.view === 'week') {
256
+ if (opts.view === 'week') {
226
257
  const weekStart = todayN - dowOfDay(todayN);
227
258
  const wa = fromDayNum(weekStart);
228
259
  const wb = fromDayNum(weekStart + 6);
229
260
  titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
230
261
  bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
231
- } else if (a.view === 'upcoming') {
262
+ } else if (opts.view === 'upcoming') {
232
263
  titleText = 'Upcoming';
233
264
  const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
234
265
  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)));
266
+ bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)));
267
+ if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
236
268
  } else {
237
- titleText = `${months[today.m - 1]} ${today.y}`;
238
- bodyHtml = monthGridHtml(today.y, today.m, todayN, byDay, events, dows, monthsShort);
269
+ titleText = `${months[fm - 1]} ${fy}`;
270
+ bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort);
239
271
  }
240
272
 
241
273
  const navButtons =
242
- a.view === 'upcoming'
274
+ opts.view === 'upcoming'
243
275
  ? ''
244
276
  : `<button type="button" class="pc-cal-btn" data-nav="today">Today</button>` +
245
277
  `<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="Previous">‹</button>` +
246
278
  `<button type="button" class="pc-cal-btn" data-nav="next" aria-label="Next">›</button>`;
247
279
 
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>`
280
+ const footer = opts.footer
281
+ ? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(opts.footer.href)}" target="_blank" rel="noopener">${esc(opts.footer.label)}</a></div>`
251
282
  : '';
252
283
 
253
284
  const payload = {
254
- view: a.view,
285
+ view: opts.view,
255
286
  today: [today.y, today.m, today.d],
256
- month: [today.y, today.m],
287
+ month: [fy, fm],
257
288
  min: [minY, minM],
258
289
  max: [maxY, maxM],
259
290
  months, monthsShort, dows, events,
291
+ details: 'Details', cancelled: 'Cancelled',
260
292
  };
261
293
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
262
294
 
263
- const multi = a.sources.length > 1 ? ' is-multi' : ''; // dots only mean something with 2+ calendars
295
+ const multi = opts.multi ? ' is-multi' : '';
264
296
  return (
265
- `<section class="pc-calendar pc-cal-view-${a.view}${multi}">${heading}` +
297
+ `<section class="pc-calendar pc-cal-view-${opts.view}${multi}">${heading}` +
266
298
  `<div class="pc-cal-frame">` +
267
299
  `<div class="pc-cal-toolbar"><span class="pc-cal-title">${esc(titleText)}</span>${navButtons}</div>` +
268
300
  `<div class="pc-cal-body">${bodyHtml}</div>` +
@@ -273,3 +305,54 @@ export async function renderCalendarBlock(a: CalendarBlockAttrs, settings: Setti
273
305
  `</section>`
274
306
  );
275
307
  }
308
+
309
+ export async function renderCalendarBlock(
310
+ a: CalendarBlockAttrs,
311
+ settings: Settings,
312
+ now: number,
313
+ repo?: ContentRepository
314
+ ): Promise<string> {
315
+ const heading =
316
+ (a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
317
+ (a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
318
+
319
+ const feeds = a.sources.filter((s) => s.startsWith('http'));
320
+ const natives = repo ? a.sources.filter(isNativeSource) : [];
321
+ if (!feeds.length && !natives.length) {
322
+ return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">No calendar connected.</p></div></section>`;
323
+ }
324
+
325
+ const win = calendarWindow(settings, now);
326
+ const { events: feedEvents, errors } = feeds.length ? await getCalendarEvents(feeds, win) : { events: [], errors: [] as string[] };
327
+ let native: ViewEvent[] = [];
328
+ let nativeCalendars = 0;
329
+ if (natives.length && repo) {
330
+ const all = natives.includes(NATIVE_ALL);
331
+ const slugs = natives.filter((s) => s !== NATIVE_ALL).map((s) => s.slice('acms:cal:'.length));
332
+ const cals = all ? [] : (await repo.listCalendars()).filter((c) => slugs.includes(c.slug));
333
+ const got = await collectViewEvents(repo, settings, win, {
334
+ audience: 'public',
335
+ ...(all ? {} : { calendarIds: cals.map((c) => c.id) }),
336
+ });
337
+ // native calendars get their own colors; shift indexes past the feeds
338
+ native = got.events.map((e) => ({ ...e, cal: e.cal + feeds.length }));
339
+ nativeCalendars = got.calendars.length;
340
+ errors.push(...got.errors);
341
+ }
342
+ const raw: ViewEvent[] = [...feedEvents, ...native].sort((x, y) => x.start - y.start || x.title.localeCompare(y.title));
343
+
344
+ if (!raw.length && feeds.length && errors.length === feeds.length && !natives.length) {
345
+ 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>`;
346
+ }
347
+
348
+ const pageUrl = a.showOpenLink ? feeds.map(calendarPageUrl).find(Boolean) ?? '' : '';
349
+ return renderCalendarView(raw, settings, now, {
350
+ view: a.view,
351
+ title: a.title,
352
+ subtitle: a.subtitle,
353
+ limit: a.limit,
354
+ footer: pageUrl ? { href: pageUrl, label: 'Open in Google Calendar ↗' } : undefined,
355
+ // dots only mean something with 2+ calendars
356
+ multi: feeds.length + nativeCalendars > 1,
357
+ });
358
+ }