@acms-subir/core 0.2.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 (230) hide show
  1. package/CHANGELOG.md +78 -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 -369
  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 +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  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 +418 -46
  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 +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  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 +605 -223
  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/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -0,0 +1,102 @@
1
+ // Event cards — the image-first "Coming up" look shared by the event card
2
+ // block (data-dyn="events"), the /calendar page and embeds. Stable
3
+ // .pc-ev-card* classes are the styling API, like .pc-cal-* for the grid.
4
+ import type { ContentRepository } from '../repo/types';
5
+ import type { CalEvent, Settings } from '../schema';
6
+ import { siteLocale } from '../dates';
7
+ import { eventUrl } from './config';
8
+ import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
9
+ import { collectViewEvents } from './data';
10
+
11
+ export interface EventCardAttrs {
12
+ mode: 'single' | 'upcoming';
13
+ eventId: string;
14
+ // calendar slug for upcoming mode; '' = every public calendar
15
+ calendar: string;
16
+ limit: number;
17
+ layout: 'grid' | 'list';
18
+ showImage: boolean;
19
+ }
20
+
21
+ const esc = (s: string) =>
22
+ String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
23
+
24
+ // javascript:/data: links must never reach an href
25
+ export const safeHref = (h: string) => (/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(h.trim()) ? h.trim() : '');
26
+
27
+ export function eventCardHtml(
28
+ o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>,
29
+ settings: Settings,
30
+ opts: { showImage: boolean; showCta?: boolean; href?: string; heading?: 'h2' | 'h3' }
31
+ ): string {
32
+ const e: CalEvent = o.event;
33
+ const tz = settings.timezone || 'UTC';
34
+ const f = formatOccurrence(o, tz, siteLocale(settings));
35
+ const href = opts.href ?? eventUrl(e.slug);
36
+ const h = opts.heading ?? 'h3';
37
+ // "Sat, Oct 3 · 11:00 AM", "Sat, Oct 3 · All day", "Sat, Oct 24 – Sun, Oct 25"
38
+ const when = f.range;
39
+ const media = opts.showImage
40
+ ? e.cover
41
+ ? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(e.cover)}" alt="${esc(e.coverAlt)}" loading="lazy" /></a>`
42
+ : `<a class="pc-ev-card-media is-tile" href="${esc(href)}" tabindex="-1" aria-hidden="true"><span class="pc-ev-tile"><b>${esc(f.month)}</b><span>${esc(f.day)}</span></span></a>`
43
+ : '';
44
+ const loc = locationLine(e.location);
45
+ const ctaHref = safeHref(e.cta.href);
46
+ const cta =
47
+ opts.showCta && e.cta.label && ctaHref && !e.cancelled
48
+ ? `<a class="pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}" href="${esc(ctaHref)}">${esc(e.cta.label)}</a>`
49
+ : '';
50
+ return (
51
+ `<article class="pc-ev-card${e.cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
52
+ `<div class="pc-ev-card-when">${e.cancelled ? '<span class="pc-ev-flag">Cancelled</span> ' : ''}${esc(when)}</div>` +
53
+ `<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || 'Untitled event')}</a></${h}>` +
54
+ (e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
55
+ (loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
56
+ cta +
57
+ `</div></article>`
58
+ );
59
+ }
60
+
61
+ // next occurrence of each distinct event, soonest first
62
+ export async function upcomingEvents(
63
+ repo: ContentRepository,
64
+ settings: Settings,
65
+ now: number,
66
+ opts: { calendarIds?: string[]; limit: number; horizonDays?: number }
67
+ ) {
68
+ const tz = settings.timezone || 'UTC';
69
+ const { events } = await collectViewEvents(repo, settings, {
70
+ displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
71
+ }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
72
+ const seen = new Set<string>();
73
+ const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[] = [];
74
+ for (const v of events) {
75
+ if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
76
+ seen.add(v.event.id);
77
+ out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days });
78
+ if (out.length >= opts.limit) break;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export async function renderEventCards(a: EventCardAttrs, repo: ContentRepository, settings: Settings, now: number): Promise<string> {
84
+ const wrap = (inner: string, extra = '') => `<section class="pc-ev-cards${extra}" data-layout="${a.layout}">${inner}</section>`;
85
+ if (a.mode === 'single') {
86
+ const e = a.eventId ? await repo.getEvent(a.eventId) : null;
87
+ const calendars = new Map((await repo.listCalendars()).map((c) => [c.id, c]));
88
+ if (!e || !eventIsPublic(e, calendars)) return wrap('<p class="pc-ev-empty">This event isn’t available.</p>', ' is-empty');
89
+ const o = featuredOccurrence(e, settings, now);
90
+ if (!o) return wrap('<p class="pc-ev-empty">This event isn’t available.</p>', ' is-empty');
91
+ return wrap(eventCardHtml(o, settings, { showImage: a.showImage, showCta: true }), ' is-single');
92
+ }
93
+ let calendarIds: string[] | undefined;
94
+ if (a.calendar) {
95
+ const c = await repo.getCalendarBySlug(a.calendar);
96
+ if (!c || c.visibility === 'private') return wrap('<p class="pc-ev-empty">No upcoming events.</p>', ' is-empty');
97
+ calendarIds = [c.id];
98
+ }
99
+ const items = await upcomingEvents(repo, settings, now, { calendarIds, limit: a.limit });
100
+ if (!items.length) return wrap('<p class="pc-ev-empty">No upcoming events.</p>', ' is-empty');
101
+ return wrap(items.map((o) => eventCardHtml(o, settings, { showImage: a.showImage })).join(''));
102
+ }
@@ -0,0 +1,52 @@
1
+ // Calendar module config, set per project via the integration:
2
+ // acms({ calendar: { base: '/calendar', eventsBase: '/events', eventFields: [...] } })
3
+ // integration.mjs validates and fills defaults before serializing, so the
4
+ // values here are always complete; the fallback only guards stale builds and
5
+ // the test stub. `enabled: false` removes every calendar route and hides the
6
+ // admin section — events stay in the database.
7
+ import { userConfig } from 'virtual:acms-config';
8
+
9
+ export type EventFieldType = 'text' | 'textarea' | 'number' | 'url' | 'select';
10
+
11
+ export interface EventFieldConfig {
12
+ key: string;
13
+ label: string;
14
+ type: EventFieldType;
15
+ options: string[];
16
+ }
17
+
18
+ export interface CalendarConfig {
19
+ enabled: boolean;
20
+ base: string;
21
+ eventsBase: string;
22
+ // bookings: rooms, people and equipment at bookBase (off = no /book routes)
23
+ bookings: boolean;
24
+ bookBase: string;
25
+ resourceKinds: string[];
26
+ defaultView: 'month' | 'week' | 'list';
27
+ eventFields: EventFieldConfig[];
28
+ }
29
+
30
+ const FALLBACK: CalendarConfig = {
31
+ enabled: true,
32
+ base: '/calendar',
33
+ eventsBase: '/events',
34
+ bookings: true,
35
+ bookBase: '/book',
36
+ resourceKinds: ['Room', 'Person', 'Vehicle', 'Equipment'],
37
+ defaultView: 'month',
38
+ eventFields: [],
39
+ };
40
+
41
+ export const CALENDAR: CalendarConfig = { ...FALLBACK, ...(userConfig.calendar ?? {}) };
42
+
43
+ export const eventUrl = (slug: string) => `${CALENDAR.eventsBase}/${slug}`;
44
+ export const eventIcsUrl = (slug: string) => `${CALENDAR.eventsBase}/${slug}.ics`;
45
+ export const calendarPageUrl = (slug?: string) => (slug ? `${CALENDAR.base}?cal=${encodeURIComponent(slug)}` : CALENDAR.base);
46
+ export const calendarFeedUrl = (slug?: string) => (slug ? `${CALENDAR.base}/${slug}.ics` : `${CALENDAR.base}.ics`);
47
+
48
+ export const bookingsEnabled = () => CALENDAR.enabled && CALENDAR.bookings;
49
+ export const resourceUrl = (slug: string) => `${CALENDAR.bookBase}/${slug}`;
50
+ export const bookingLinkUrl = (resourceSlug: string, linkSlug: string) => `${CALENDAR.bookBase}/${resourceSlug}/${linkSlug}`;
51
+ export const manageUrl = (token: string) => `${CALENDAR.bookBase}/manage/${token}`;
52
+ export const resourceFeedUrl = (slug: string, token: string) => `${CALENDAR.bookBase}/${slug}.ics?t=${encodeURIComponent(token)}`;
@@ -0,0 +1,130 @@
1
+ // One place that answers "which occurrences does this view show?" for every
2
+ // calendar surface — the /calendar page, the calendar card, embeds, the admin
3
+ // agenda. Native events are expanded from the DB; feed calendars are fetched
4
+ // live through the cached ICS loader. Visibility rules live in events.ts.
5
+ import type { ContentRepository } from '../repo/types';
6
+ import type { CalEvent, Calendar, Settings } from '../schema';
7
+ import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
8
+ import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
9
+ import { CALENDAR, eventUrl, resourceUrl } from './config';
10
+
11
+ export interface ViewEvent extends CalendarEvent {
12
+ url?: string;
13
+ color?: string;
14
+ cancelled?: boolean;
15
+ image?: string;
16
+ imageAlt?: string;
17
+ calendarName?: string;
18
+ event?: CalEvent;
19
+ }
20
+
21
+ export interface ViewScope {
22
+ audience: 'public' | 'admin';
23
+ // limit to these calendars; omitted = every calendar the audience may see
24
+ calendarIds?: string[];
25
+ // native events outside any calendar (public only when unscoped)
26
+ includeUncategorized?: boolean;
27
+ // feeds are network calls — views that don't need them can skip
28
+ includeFeeds?: boolean;
29
+ // "Reserved" blocks from resources set to show on the calendar
30
+ includeBookings?: boolean;
31
+ }
32
+
33
+ const pad = (n: number) => String(n).padStart(2, '0');
34
+
35
+ export async function collectViewEvents(
36
+ repo: ContentRepository,
37
+ settings: Pick<Settings, 'timezone'>,
38
+ win: CalendarWindow,
39
+ scope: ViewScope
40
+ ): Promise<{ events: ViewEvent[]; calendars: Calendar[]; errors: string[] }> {
41
+ const all = await repo.listCalendars();
42
+ const byId = new Map(all.map((c) => [c.id, c]));
43
+ const requested = scope.calendarIds ? new Set(scope.calendarIds) : null;
44
+ const visible = all.filter((c) => {
45
+ if (scope.audience === 'admin') return true;
46
+ // a directly requested calendar may be unlisted; private never is
47
+ return requested ? c.visibility !== 'private' : c.visibility === 'public';
48
+ });
49
+ const inScope = requested ? visible.filter((c) => requested.has(c.id)) : visible;
50
+ const idx = new Map(inScope.map((c, i) => [c.id, i]));
51
+ const scoped = new Set(inScope.map((c) => c.id));
52
+ const tz = settings.timezone || 'UTC';
53
+
54
+ const source = scope.audience === 'admin' ? (await repo.listEvents()).filter((e) => e.status !== 'archived') : await repo.listPublishedEvents();
55
+ const natives = source.filter((e) => {
56
+ if (scope.audience === 'public' && !eventIsPublic(e, byId)) return false;
57
+ if (!e.calendarIds.length) {
58
+ if (requested) return false;
59
+ return scope.includeUncategorized ?? true;
60
+ }
61
+ if (!requested && scope.audience === 'public') return eventIsListed(e, byId);
62
+ return e.calendarIds.some((id) => scoped.has(id));
63
+ });
64
+
65
+ const events: ViewEvent[] = [];
66
+ for (const o of expandEvents(natives, settings, win)) {
67
+ const e = o.event;
68
+ const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
69
+ const p = tzParts(o.start, tz);
70
+ const on = e.rrule ? `?on=${p.y}-${pad(p.m)}-${pad(p.d)}` : '';
71
+ events.push({
72
+ start: o.start, end: o.end, allDay: o.allDay, days: o.days,
73
+ title: e.title || 'Untitled event',
74
+ location: [e.location.name, e.location.address].filter(Boolean).join(', '),
75
+ description: e.excerpt,
76
+ cal: cal ? idx.get(cal.id) ?? 0 : 0,
77
+ ref: e.id,
78
+ url: eventUrl(e.slug) + on,
79
+ color: cal ? safeColor(cal.color) : undefined,
80
+ cancelled: e.cancelled,
81
+ image: e.cover || undefined,
82
+ imageAlt: e.coverAlt || undefined,
83
+ calendarName: cal?.name,
84
+ event: e,
85
+ });
86
+ }
87
+
88
+ const errors: string[] = [];
89
+ if (scope.includeFeeds ?? true) {
90
+ const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl);
91
+ const results = await Promise.all(
92
+ feeds.map(async (c) => {
93
+ try {
94
+ const feed = await loadFeed(c.feedUrl, win.now);
95
+ return expandVEvents(feed.vevents, feed.calTz, win, idx.get(c.id) ?? 0).map<ViewEvent>((ev) => ({
96
+ ...ev, color: safeColor(c.color), calendarName: c.name,
97
+ }));
98
+ } catch (err) {
99
+ errors.push(`${c.name}: ${err instanceof Error ? err.message : 'feed failed'}`);
100
+ return [];
101
+ }
102
+ })
103
+ );
104
+ events.push(...results.flat());
105
+ }
106
+
107
+ // resources marked "show on calendar": confirmed bookings appear as
108
+ // Reserved blocks (no guest names on public views) — only on unscoped views
109
+ if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
110
+ const resources = (await repo.listResources()).filter((r) => r.showOnCalendar);
111
+ if (resources.length) {
112
+ const booked = await repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] });
113
+ const byId = new Map(resources.map((r) => [r.id, r]));
114
+ for (const b of booked) {
115
+ const r = byId.get(b.resourceId);
116
+ if (!r) continue;
117
+ const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
118
+ events.push({
119
+ start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
120
+ title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `Reserved · ${r.name}`,
121
+ location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
122
+ url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
123
+ });
124
+ }
125
+ }
126
+ }
127
+
128
+ events.sort((a, b) => a.start - b.start || a.title.localeCompare(b.title));
129
+ return { events: events.slice(0, 1500), calendars: inScope, errors };
130
+ }
@@ -0,0 +1,261 @@
1
+ // Native events: wall-clock time math, recurrence expansion (reusing the ICS
2
+ // expander, so feed events and native events follow identical rules),
3
+ // visibility, and "add to calendar" links. Pure — no repo, no fetch.
4
+ import type { CalEvent, Calendar, EventLocation, Settings } from '../schema';
5
+ import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
6
+ import { cleanRrule } from './rrule';
7
+
8
+ const DAY = 86_400_000;
9
+
10
+ export interface Wall {
11
+ y: number; m: number; d: number; hh: number; mm: number;
12
+ dateOnly: boolean;
13
+ }
14
+
15
+ const pad = (n: number) => String(n).padStart(2, '0');
16
+
17
+ export function parseWall(s: string | undefined | null): Wall | null {
18
+ if (!s) return null;
19
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::\d{2})?)?$/.exec(s.trim());
20
+ if (!m) return null;
21
+ const w: Wall = { y: +m[1], m: +m[2], d: +m[3], hh: m[4] ? +m[4] : 0, mm: m[5] ? +m[5] : 0, dateOnly: !m[4] };
22
+ const probe = new Date(Date.UTC(w.y, w.m - 1, w.d));
23
+ if (probe.getUTCMonth() !== w.m - 1 || probe.getUTCDate() !== w.d || w.hh > 23 || w.mm > 59) return null;
24
+ return w;
25
+ }
26
+
27
+ export const wallDate = (w: Wall) => `${w.y}-${pad(w.m)}-${pad(w.d)}`;
28
+ export const formatWall = (w: Wall) => (w.dateOnly ? wallDate(w) : `${wallDate(w)}T${pad(w.hh)}:${pad(w.mm)}`);
29
+
30
+ // wall-clock arithmetic in "floating" time (no zone): minutes since epoch
31
+ const wallMinutes = (w: Wall) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm) / 60_000;
32
+ function fromMinutes(min: number, dateOnly: boolean): Wall {
33
+ const dt = new Date(min * 60_000);
34
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), hh: dateOnly ? 0 : dt.getUTCHours(), mm: dateOnly ? 0 : dt.getUTCMinutes(), dateOnly };
35
+ }
36
+ export const addWallDays = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n * 1440, w.dateOnly);
37
+
38
+ export function isValidTimeZone(tz: string): boolean {
39
+ if (!tz) return false;
40
+ try {
41
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function eventTimeZone(e: Pick<CalEvent, 'timezone'>, settings: Pick<Settings, 'timezone'>): string {
49
+ const tz = e.timezone || settings.timezone || 'UTC';
50
+ return isValidTimeZone(tz) ? tz : 'UTC';
51
+ }
52
+
53
+ const wallToMs = (w: Wall, tz: string) => zonedToUtc(w.y, w.m, w.d, w.dateOnly ? 0 : w.hh, w.dateOnly ? 0 : w.mm, 0, tz);
54
+
55
+ // Validate + fill defaults for a start/end pair. Timed events default to one
56
+ // hour and never end before they start; all-day events store their inclusive
57
+ // last day. Throws a user-facing message on unparseable input.
58
+ export function normalizeEventTimes(input: { start: string; end?: string; allDay: boolean }): { start: string; end: string } {
59
+ const s = parseWall(input.start);
60
+ if (!s) throw new Error('Start must be a date (YYYY-MM-DD) or a date and time (YYYY-MM-DDTHH:mm).');
61
+ const e = parseWall(input.end);
62
+ if (input.allDay) {
63
+ const sd: Wall = { ...s, hh: 0, mm: 0, dateOnly: true };
64
+ let ed: Wall = e ? { ...e, hh: 0, mm: 0, dateOnly: true } : sd;
65
+ if (wallMinutes(ed) < wallMinutes(sd)) ed = sd;
66
+ return { start: formatWall(sd), end: formatWall(ed) };
67
+ }
68
+ const st: Wall = s.dateOnly ? { ...s, hh: 9, mm: 0, dateOnly: false } : s;
69
+ let et: Wall | null = e ? (e.dateOnly ? { ...e, hh: st.hh, mm: st.mm, dateOnly: false } : e) : null;
70
+ if (!et || wallMinutes(et) <= wallMinutes(st)) et = fromMinutes(wallMinutes(st) + 60, false);
71
+ return { start: formatWall(st), end: formatWall(et) };
72
+ }
73
+
74
+ // UTC instants of the first occurrence (all-day: midnight → midnight after
75
+ // the last day, in the event's zone)
76
+ export function eventInstants(e: Pick<CalEvent, 'start' | 'end' | 'allDay'>, tz: string): { startAt: number; endAt: number } {
77
+ const s = parseWall(e.start);
78
+ if (!s) return { startAt: 0, endAt: 0 };
79
+ const en = parseWall(e.end) ?? s;
80
+ if (e.allDay) {
81
+ return { startAt: wallToMs({ ...s, dateOnly: true }, tz), endAt: wallToMs(addWallDays({ ...en, dateOnly: true }, 1), tz) };
82
+ }
83
+ return { startAt: wallToMs(s, tz), endAt: wallToMs(en, tz) };
84
+ }
85
+
86
+ export const locationLine = (loc: EventLocation) => [loc.name, loc.address].filter(Boolean).join(', ');
87
+
88
+ export function toVEvent(e: CalEvent, tz: string): VEvent | null {
89
+ const s = parseWall(e.start);
90
+ if (!s) return null;
91
+ const en = parseWall(e.end) ?? s;
92
+ const t = (w: Wall, dateOnly: boolean): IcsTime => ({
93
+ y: w.y, m: w.m, d: w.d, hh: dateOnly ? 0 : w.hh, mm: dateOnly ? 0 : w.mm, ss: 0,
94
+ tz: dateOnly ? null : tz, dateOnly, utc: false,
95
+ });
96
+ return {
97
+ uid: e.id,
98
+ ref: e.id,
99
+ title: e.title,
100
+ location: locationLine(e.location),
101
+ description: e.excerpt,
102
+ // cancelled events stay on the calendar (struck through), so the expander
103
+ // must still produce them
104
+ cancelled: false,
105
+ start: t(s, e.allDay),
106
+ end: e.allDay ? t(addWallDays({ ...en, dateOnly: true }, 1), true) : t(en, false),
107
+ rrule: cleanRrule(e.rrule),
108
+ exdates: e.exdates.map(parseWall).filter((w): w is Wall => w !== null).map((w) => t(w, e.allDay)),
109
+ recurrenceId: null,
110
+ };
111
+ }
112
+
113
+ export interface Occurrence {
114
+ event: CalEvent;
115
+ start: number;
116
+ end: number;
117
+ allDay: boolean;
118
+ days: number;
119
+ }
120
+
121
+ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timezone'>, win: CalendarWindow): Occurrence[] {
122
+ const out: Occurrence[] = [];
123
+ for (const e of events) {
124
+ const tz = eventTimeZone(e, settings);
125
+ const v = toVEvent(e, tz);
126
+ if (!v) continue;
127
+ for (const o of expandVEvents([v], tz, win, 0)) {
128
+ out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days });
129
+ }
130
+ }
131
+ return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
132
+ }
133
+
134
+ // The occurrence to feature for an event: the next one that hasn't ended,
135
+ // else the most recent. `on` ('YYYY-MM-DD') picks a specific day's instance
136
+ // (links from the calendar grid carry it).
137
+ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number, on?: string): Occurrence | null {
138
+ const tz = eventTimeZone(e, settings);
139
+ const displayTz = settings.timezone || 'UTC';
140
+ if (on) {
141
+ const w = parseWall(on);
142
+ if (w) {
143
+ const from = zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz);
144
+ const hit = expandEvents([e], settings, { displayTz, now, windowStartMs: from, windowEndMs: from + DAY })
145
+ .find((o) => o.start >= from - DAY);
146
+ if (hit) return hit;
147
+ }
148
+ }
149
+ if (!e.rrule) {
150
+ const { startAt, endAt } = eventInstants(e, tz);
151
+ const s = parseWall(e.start);
152
+ const en = parseWall(e.end) ?? s;
153
+ const days = e.allDay && s && en ? Math.round((wallMinutes(en) - wallMinutes(s)) / 1440) + 1 : 1;
154
+ return { event: e, start: startAt, end: endAt, allDay: e.allDay, days };
155
+ }
156
+ const next = expandEvents([e], settings, { displayTz, now, windowStartMs: now, windowEndMs: now + 3 * 366 * DAY })[0];
157
+ if (next) return next;
158
+ const past = expandEvents([e], settings, { displayTz, now, windowStartMs: now - 3 * 366 * DAY, windowEndMs: now });
159
+ return past[past.length - 1] ?? null;
160
+ }
161
+
162
+ // ---- visibility ----------------------------------------------------------
163
+ // An event with no calendars counts as public. Otherwise it follows its most
164
+ // open calendar: any public/unlisted calendar makes the event page public;
165
+ // only public calendars put it on the main calendar page and site feed.
166
+
167
+ export function eventIsPublic(e: CalEvent, calendars: Map<string, Calendar>): boolean {
168
+ if (e.status !== 'published') return false;
169
+ if (!e.calendarIds.length) return true;
170
+ return e.calendarIds.some((id) => {
171
+ const c = calendars.get(id);
172
+ return c ? c.visibility !== 'private' : false;
173
+ });
174
+ }
175
+
176
+ export function eventIsListed(e: CalEvent, calendars: Map<string, Calendar>): boolean {
177
+ if (e.status !== 'published') return false;
178
+ if (!e.calendarIds.length) return true;
179
+ return e.calendarIds.some((id) => calendars.get(id)?.visibility === 'public');
180
+ }
181
+
182
+ // ---- formatting ----------------------------------------------------------
183
+
184
+ export function formatOccurrence(
185
+ o: Pick<Occurrence, 'start' | 'end' | 'allDay' | 'days'>,
186
+ tz: string,
187
+ locale: string
188
+ ): { date: string; time: string; range: string; month: string; day: string; weekday: string } {
189
+ const safe = (opts: Intl.DateTimeFormatOptions) => {
190
+ try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }); }
191
+ catch { return new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }); }
192
+ };
193
+ const dateFmt = safe({ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
194
+ const shortFmt = safe({ weekday: 'short', month: 'short', day: 'numeric' });
195
+ const timeFmt = safe({ hour: 'numeric', minute: '2-digit' });
196
+ const zoneFmt = safe({ hour: 'numeric', timeZoneName: 'short' });
197
+ const zone = zoneFmt.formatToParts(o.start).find((p) => p.type === 'timeZoneName')?.value ?? '';
198
+ const date = dateFmt.format(o.start);
199
+ let time: string;
200
+ let range: string;
201
+ if (o.allDay) {
202
+ time = o.days > 1 ? `${shortFmt.format(o.start)} – ${shortFmt.format(o.end - DAY)}` : 'All day';
203
+ range = o.days > 1 ? time : `${shortFmt.format(o.start)} · All day`;
204
+ } else {
205
+ time = `${timeFmt.format(o.start)} – ${timeFmt.format(o.end)}${zone ? ` ${zone}` : ''}`;
206
+ range = `${shortFmt.format(o.start)} · ${timeFmt.format(o.start)}`;
207
+ }
208
+ const p = tzParts(o.start, tz);
209
+ return {
210
+ date, time, range,
211
+ month: safe({ month: 'short' }).format(o.start).toUpperCase(),
212
+ day: String(p.d),
213
+ weekday: safe({ weekday: 'short' }).format(o.start),
214
+ };
215
+ }
216
+
217
+ // ---- add to calendar -----------------------------------------------------
218
+
219
+ const utcStamp = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
220
+ const dateStamp = (ms: number, tz: string) => {
221
+ const p = tzParts(ms, tz);
222
+ return `${p.y}${pad(p.m)}${pad(p.d)}`;
223
+ };
224
+
225
+ export function addToCalendarLinks(
226
+ e: CalEvent,
227
+ o: Pick<Occurrence, 'start' | 'end' | 'allDay'>,
228
+ opts: { pageUrl: string; icsUrl: string; tz: string }
229
+ ): { google: string; outlook: string; ics: string } {
230
+ const details = [e.excerpt, opts.pageUrl].filter(Boolean).join('\n\n');
231
+ const loc = [locationLine(e.location), e.location.url].filter(Boolean).join(' · ');
232
+ const g = new URLSearchParams({
233
+ action: 'TEMPLATE',
234
+ text: e.title,
235
+ dates: o.allDay ? `${dateStamp(o.start, opts.tz)}/${dateStamp(o.end, opts.tz)}` : `${utcStamp(o.start)}/${utcStamp(o.end)}`,
236
+ details,
237
+ location: loc,
238
+ });
239
+ if (!o.allDay) g.set('ctz', opts.tz);
240
+ const rule = cleanRrule(e.rrule);
241
+ if (rule) g.set('recur', `RRULE:${rule}`);
242
+ const iso = (ms: number) => new Date(ms).toISOString();
243
+ const ol = new URLSearchParams({
244
+ path: '/calendar/action/compose',
245
+ rru: 'addevent',
246
+ subject: e.title,
247
+ startdt: o.allDay ? `${dateStamp(o.start, opts.tz).replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}` : iso(o.start),
248
+ enddt: o.allDay ? `${dateStamp(o.end, opts.tz).replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}` : iso(o.end),
249
+ body: details,
250
+ location: loc,
251
+ allday: o.allDay ? 'true' : 'false',
252
+ });
253
+ return {
254
+ google: `https://calendar.google.com/calendar/render?${g}`,
255
+ outlook: `https://outlook.live.com/calendar/0/deeplink/compose?${ol}`,
256
+ ics: opts.icsUrl,
257
+ };
258
+ }
259
+
260
+ // a #rrggbb color or the default — calendar colors reach inline styles
261
+ export const safeColor = (c: string, fallback = '#4f46e5') => (/^#[0-9a-f]{6}$/i.test(c) ? c.toLowerCase() : fallback);
@@ -0,0 +1,43 @@
1
+ // Shared plumbing for the ICS routes: which events a feed carries and the
2
+ // response headers calendar apps expect.
3
+ import type { ContentRepository } from '../repo/types';
4
+ import type { CalEvent, Calendar, Settings } from '../schema';
5
+ import { buildFeed } from './ics-write';
6
+ import { eventIsListed, eventIsPublic } from './events';
7
+ import { eventUrl } from './config';
8
+
9
+ export function icsResponse(body: string, filename: string, cacheSeconds = 300): Response {
10
+ return new Response(body, {
11
+ headers: {
12
+ 'Content-Type': 'text/calendar; charset=utf-8',
13
+ 'Content-Disposition': `inline; filename="${filename.replace(/[^a-z0-9._-]/gi, '')}"`,
14
+ 'Cache-Control': `public, max-age=${cacheSeconds}`,
15
+ 'Access-Control-Allow-Origin': '*',
16
+ },
17
+ });
18
+ }
19
+
20
+ export async function feedFor(
21
+ repo: ContentRepository,
22
+ settings: Settings,
23
+ origin: string,
24
+ scope: { calendar?: Calendar; allowPrivate?: boolean }
25
+ ): Promise<{ events: CalEvent[]; body: string }> {
26
+ const [published, calendars] = await Promise.all([repo.listPublishedEvents(), repo.listCalendars()]);
27
+ const map = new Map(calendars.map((c) => [c.id, c]));
28
+ const c = scope.calendar;
29
+ const events = published.filter((e) =>
30
+ c
31
+ ? e.calendarIds.includes(c.id) && (scope.allowPrivate || eventIsPublic(e, map) || c.visibility !== 'private')
32
+ : eventIsListed(e, map)
33
+ );
34
+ const host = new URL(origin).host;
35
+ const body = buildFeed(events, settings.timezone, {
36
+ name: c ? `${c.name} · ${settings.siteTitle}` : settings.siteTitle,
37
+ description: c?.description || settings.tagline,
38
+ origin, host, tz: settings.timezone || 'UTC',
39
+ eventUrl: (e) => eventUrl(e.slug),
40
+ calendars: map,
41
+ });
42
+ return { events, body };
43
+ }
@@ -0,0 +1,38 @@
1
+ // Side effects when an event changes live: a 301 when a published event's
2
+ // slug moves, and the matching webhook (event.published / event.updated /
3
+ // event.unpublished). Shared by the editor actions, the list's bulk actions
4
+ // and the agent tools so every path behaves the same.
5
+ import type { ContentRepository } from '../repo/types';
6
+ import type { CalEvent } from '../schema';
7
+ import { emitWebhook } from '../webhooks';
8
+ import { eventToJson } from './api';
9
+ import { eventUrl } from './config';
10
+
11
+ export async function eventChanged(
12
+ repo: ContentRepository,
13
+ before: CalEvent,
14
+ after: CalEvent,
15
+ opts: { origin: string; waitUntil?: (p: Promise<unknown>) => void; applied?: boolean; onRedirect?: () => void }
16
+ ): Promise<void> {
17
+ if (before.status === 'published' && after.status === 'published' && before.slug !== after.slug) {
18
+ await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
19
+ opts.onRedirect?.();
20
+ }
21
+ let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
22
+ if (before.status !== 'published' && after.status === 'published') type = 'event.published';
23
+ else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
24
+ else if (after.status === 'published' && (opts.applied || before.updatedAt !== after.updatedAt)) type = 'event.updated';
25
+ if (!type) return;
26
+ const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
27
+ const data = eventToJson(after, opts.origin, new Map(calendars.map((c) => [c.id, c])), settings.timezone);
28
+ await emitWebhook(repo, type, { event: data }, { waitUntil: opts.waitUntil }).catch(() => {});
29
+ }
30
+
31
+ export async function eventDeleted(
32
+ repo: ContentRepository,
33
+ before: CalEvent | null,
34
+ opts: { waitUntil?: (p: Promise<unknown>) => void }
35
+ ): Promise<void> {
36
+ if (before?.status !== 'published') return;
37
+ await emitWebhook(repo, 'event.deleted', { event: { id: before.id, slug: before.slug, title: before.title } }, opts).catch(() => {});
38
+ }
@@ -0,0 +1,30 @@
1
+ // Shared plumbing for the /api/calendar/* routes: CORS (other sites and
2
+ // apps call these from browsers), JSON errors, and the booking-error bridge.
3
+ import { ApiError, apiError } from '../api';
4
+ import { BookingError } from '../booking/service';
5
+
6
+ export const CORS: Record<string, string> = {
7
+ 'Access-Control-Allow-Origin': '*',
8
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
9
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
10
+ 'Access-Control-Max-Age': '86400',
11
+ };
12
+
13
+ export const preflight = () => new Response(null, { status: 204, headers: CORS });
14
+
15
+ export function withCors(res: Response): Response {
16
+ for (const [k, v] of Object.entries(CORS)) res.headers.set(k, v);
17
+ return res;
18
+ }
19
+
20
+ export function fail(error: unknown): Response {
21
+ if (error instanceof BookingError) return withCors(apiError(new ApiError(error.status, error.message)));
22
+ return withCors(apiError(error));
23
+ }
24
+
25
+ // ISO string or epoch ms → epoch ms
26
+ export function toMs(v: unknown, label: string): number {
27
+ const n = typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : Date.parse(String(v ?? ''));
28
+ if (!Number.isFinite(n)) throw new ApiError(400, `"${label}" must be an ISO date-time or epoch milliseconds.`);
29
+ return n;
30
+ }