@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,191 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { effectiveSettings } from '../../lib/designPreview';
5
+ import { siteOrigin } from '../../lib/seo';
6
+ import { siteLocale } from '../../lib/dates';
7
+ import { CALENDAR, calendarFeedUrl, calendarPageUrl } from '../../lib/calendar/config';
8
+ import { calendarWindow, renderCalendarView } from '../../lib/calendar/render';
9
+ import { collectViewEvents, type ViewEvent } from '../../lib/calendar/data';
10
+ import { eventCardHtml } from '../../lib/calendar/cards';
11
+ import { safeColor } from '../../lib/calendar/events';
12
+ import { tzParts } from '../../lib/calendar/ics';
13
+
14
+ // The main calendar page: filter chips per public calendar, Month / Week /
15
+ // List views (the same renderer as the calendar card), image cards for
16
+ // what's coming up, and subscribe links for every calendar app. On phones the
17
+ // month grid gives way to a two-week day strip over the agenda.
18
+ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
19
+
20
+ const repo = await getRepo(Astro.locals);
21
+ const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
22
+ const now = Date.now();
23
+ const tz = settings.timezone || 'UTC';
24
+ const locale = siteLocale(settings);
25
+ const origin = siteOrigin(settings, Astro.url);
26
+
27
+ const all = await repo.listCalendars();
28
+ const listed = all.filter((c) => c.visibility === 'public');
29
+ const calSlug = Astro.url.searchParams.get('cal') ?? '';
30
+ // unlisted calendars work when linked directly, they just aren't offered
31
+ const active = calSlug ? all.find((c) => c.slug === calSlug && c.visibility !== 'private') ?? null : null;
32
+ const viewParam = Astro.url.searchParams.get('view');
33
+ const view = (['month', 'week', 'list'] as const).find((v) => v === viewParam) ?? CALENDAR.defaultView;
34
+ const explicitView = Boolean(viewParam);
35
+ const monthParam = /^(\d{4})-(\d{2})$/.exec(Astro.url.searchParams.get('m') ?? '');
36
+
37
+ const win = calendarWindow(settings, now);
38
+ const { events, calendars, errors } = await collectViewEvents(repo, settings, win, {
39
+ audience: 'public',
40
+ ...(active ? { calendarIds: [active.id] } : {}),
41
+ });
42
+
43
+ const calendarHtml = renderCalendarView(events, settings, now, {
44
+ view: view === 'list' ? 'upcoming' : view,
45
+ limit: 50,
46
+ multi: !active && calendars.length > 1,
47
+ emptyText: 'Nothing on the calendar yet.',
48
+ focus: monthParam ? { y: Number(monthParam[1]), m: Number(monthParam[2]) } : undefined,
49
+ });
50
+
51
+ // coming up: the next occurrence of each distinct native event
52
+ const seen = new Set<string>();
53
+ const comingUp: ViewEvent[] = [];
54
+ for (const v of events) {
55
+ if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
56
+ seen.add(v.event.id);
57
+ comingUp.push(v);
58
+ if (comingUp.length >= 6) break;
59
+ }
60
+
61
+ // agenda (list view + phones): next 60 days grouped by local day
62
+ const pad = (n: number) => String(n).padStart(2, '0');
63
+ const dayKey = (ms: number) => { const p = tzParts(ms, tz); return `${p.y}-${pad(p.m)}-${pad(p.d)}`; };
64
+ const horizon = now + 60 * 86_400_000;
65
+ const agenda = new Map<string, ViewEvent[]>();
66
+ for (const v of events) {
67
+ if (v.end <= now || v.start > horizon) continue;
68
+ // events already underway are listed under today
69
+ const k = dayKey(Math.max(v.start, now));
70
+ if (!agenda.has(k)) agenda.set(k, []);
71
+ agenda.get(k)!.push(v);
72
+ }
73
+ const fmt = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: tz });
74
+ const dayLabel = fmt({ weekday: 'long', month: 'long', day: 'numeric' });
75
+ const timeLabel = fmt({ hour: 'numeric', minute: '2-digit' });
76
+ const stripDays = Array.from({ length: 14 }, (_, i) => {
77
+ const ms = now + i * 86_400_000;
78
+ const k = dayKey(ms);
79
+ return { k, dow: fmt({ weekday: 'narrow' }).format(ms), d: tzParts(ms, tz).d, has: agenda.has(k), today: i === 0 };
80
+ });
81
+
82
+ const feed = origin + calendarFeedUrl(active?.slug);
83
+ const webcal = feed.replace(/^https?:\/\//, 'webcal://');
84
+ const googleAdd = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`;
85
+ const outlookAdd = `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(feed)}&name=${encodeURIComponent(active?.name ?? settings.siteTitle)}`;
86
+ const viewHref = (v: string) => {
87
+ const p = new URLSearchParams(Astro.url.search);
88
+ p.set('view', v);
89
+ p.delete('m');
90
+ return `${CALENDAR.base}?${p}`;
91
+ };
92
+ const title = active ? active.name : 'Calendar';
93
+ const lede = active?.description || `What’s happening at ${settings.siteTitle}. Subscribe to keep it in your own calendar.`;
94
+ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.type === 'timeZoneName')?.value ?? tz;
95
+ ---
96
+
97
+ <Site title={title} description={lede}>
98
+ <div class={`pc-calpage view-${view}${explicitView ? ' is-explicit' : ''}`}>
99
+ <header class="pc-calpage-head">
100
+ <h1>{title}</h1>
101
+ <p>{lede}</p>
102
+ </header>
103
+
104
+ <div class="pc-calpage-bar">
105
+ {(listed.length > 1 || active) && (
106
+ <nav class="pc-calpage-chips" aria-label="Calendars">
107
+ <a class={!active ? 'is-on' : ''} href={CALENDAR.base} aria-current={!active ? 'page' : undefined}>All</a>
108
+ {listed.map((c) => (
109
+ <a class={active?.id === c.id ? 'is-on' : ''} href={calendarPageUrl(c.slug)} aria-current={active?.id === c.id ? 'page' : undefined}>
110
+ <i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}
111
+ </a>
112
+ ))}
113
+ {active && active.visibility !== 'public' && <a class="is-on" href={calendarPageUrl(active.slug)}><i style={`background:${safeColor(active.color)}`} aria-hidden="true" />{active.name}</a>}
114
+ </nav>
115
+ )}
116
+ <div class="pc-calpage-tools">
117
+ <nav class="pc-calpage-views" aria-label="View">
118
+ <a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>Month</a>
119
+ <a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>Week</a>
120
+ <a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>List</a>
121
+ </nav>
122
+ <details class="pc-calpage-sub">
123
+ <summary>
124
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg>
125
+ Subscribe
126
+ </summary>
127
+ <div class="pc-calpage-sub-menu">
128
+ <p>Add {active ? `“${active.name}”` : 'this calendar'} to your calendar app. New events show up on their own.</p>
129
+ <a href={googleAdd} target="_blank" rel="noopener">Google Calendar</a>
130
+ <a href={webcal}>Apple Calendar</a>
131
+ <a href={outlookAdd} target="_blank" rel="noopener">Outlook</a>
132
+ <button type="button" data-copy={feed}>Copy link</button>
133
+ </div>
134
+ </details>
135
+ </div>
136
+ </div>
137
+
138
+ <div class="pc-calpage-strip" aria-label="Next two weeks">
139
+ {stripDays.map((d) => (
140
+ <a class={`${d.has ? 'has' : ''}${d.today ? ' today' : ''}`} href={d.has ? `#d-${d.k}` : undefined} aria-label={d.k}>
141
+ <small>{d.dow}</small><b>{d.d}</b><i aria-hidden="true" />
142
+ </a>
143
+ ))}
144
+ </div>
145
+
146
+ <div class="pc-calpage-view" set:html={calendarHtml} />
147
+
148
+ <section class="pc-calpage-agenda" aria-label="Agenda">
149
+ {agenda.size === 0 ? <p class="pc-cal-empty">Nothing in the next two months.</p> : [...agenda.entries()].map(([k, list]) => (
150
+ <div class="pc-agenda-day" id={`d-${k}`}>
151
+ <h3>{dayLabel.format(list[0].start < now ? now : list[0].start)}</h3>
152
+ {list.map((v) => (
153
+ <a class={`pc-agenda-row${v.cancelled ? ' is-cancelled' : ''}`} href={v.url ?? undefined}>
154
+ <span class="t">{v.allDay ? 'All day' : timeLabel.format(v.start)}</span>
155
+ <span class="ti">
156
+ {v.color && <i style={`background:${v.color}`} aria-hidden="true" />}
157
+ <b>{v.title}</b>
158
+ {v.location && <small>{v.location}</small>}
159
+ </span>
160
+ </a>
161
+ ))}
162
+ </div>
163
+ ))}
164
+ </section>
165
+
166
+ {view !== 'list' && comingUp.length > 0 && (
167
+ <section class="pc-calpage-up">
168
+ <h2>Coming up</h2>
169
+ <div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days }, settings, { showImage: true })).join('')} />
170
+ </section>
171
+ )}
172
+
173
+ <footer class="pc-calpage-foot">
174
+ <span>Times in {zoneName}</span>
175
+ {errors.length > 0 && <span>Some linked calendars couldn’t be loaded right now.</span>}
176
+ </footer>
177
+ </div>
178
+ <script>
179
+ document.addEventListener('click', async (e) => {
180
+ const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
181
+ if (b) {
182
+ await navigator.clipboard.writeText(b.dataset.copy ?? '');
183
+ b.textContent = 'Link copied';
184
+ setTimeout(() => (b.textContent = 'Copy link'), 1400);
185
+ }
186
+ document.querySelectorAll<HTMLDetailsElement>('details.pc-calpage-sub[open]').forEach((d) => {
187
+ if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
188
+ });
189
+ });
190
+ </script>
191
+ </Site>
@@ -0,0 +1,33 @@
1
+ ---
2
+ import Site from '../../../../layouts/Site.astro';
3
+ import BookingFlow from '../../../../components/booking/BookingFlow.astro';
4
+ import { getRepo } from '../../../../lib/repo';
5
+ import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config';
6
+ import { availabilityContext } from '../../../../lib/booking/service';
7
+
8
+ // Embeddable picker for other sites: day + time selection happens inside the
9
+ // iframe; choosing a time opens the full booking page (top window) to finish.
10
+ if (!bookingsEnabled()) return new Response(null, { status: 404 });
11
+ const repo = await getRepo(Astro.locals);
12
+ const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
13
+ const link = resource ? await repo.getBookingLinkBySlug(resource.id, Astro.params.link ?? '') : null;
14
+ if (!resource || !link || !link.active) return new Response(null, { status: 404 });
15
+ const settings = await repo.getSettings();
16
+ // a chosen time hands off to the real page, where the form lives
17
+ if (Astro.url.searchParams.get('start')) {
18
+ return Astro.redirect(`${bookingLinkUrl(resource.slug, link.slug)}${Astro.url.search}`);
19
+ }
20
+ const ctx = await availabilityContext(repo, resource, link, settings);
21
+ Astro.response.headers.set('Content-Security-Policy', 'frame-ancestors *');
22
+ ---
23
+
24
+ <Site title={link.name} bare noindex>
25
+ <BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={Astro.url} embed />
26
+ <script is:inline>
27
+ (function () {
28
+ var send = function () { parent.postMessage({ acmsBookingEmbed: true, height: document.documentElement.scrollHeight }, '*'); };
29
+ window.addEventListener('load', send);
30
+ if (window.ResizeObserver) new ResizeObserver(send).observe(document.documentElement);
31
+ })();
32
+ </script>
33
+ </Site>
@@ -0,0 +1,43 @@
1
+ ---
2
+ import Site from '../../../layouts/Site.astro';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { CALENDAR } from '../../../lib/calendar/config';
5
+ import { calendarWindow, renderCalendarView } from '../../../lib/calendar/render';
6
+ import { collectViewEvents } from '../../../lib/calendar/data';
7
+ import { upcomingEvents, eventCardHtml } from '../../../lib/calendar/cards';
8
+
9
+ // Embeddable calendar for other websites:
10
+ // <iframe src="https://site/embed/calendar/all?view=month" …>
11
+ // slug 'all' = every public calendar. view: month | week | list | cards.
12
+ // Links open in the parent window; height is posted to the parent so a
13
+ // small script can auto-size the iframe.
14
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
15
+ const repo = await getRepo(Astro.locals);
16
+ const settings = await repo.getSettings();
17
+ const slug = Astro.params.slug ?? 'all';
18
+ const cal = slug === 'all' ? null : await repo.getCalendarBySlug(slug);
19
+ if (slug !== 'all' && (!cal || cal.visibility === 'private')) return new Response(null, { status: 404 });
20
+ const view = (['month', 'week', 'list', 'cards'] as const).find((v) => v === Astro.url.searchParams.get('view')) ?? 'month';
21
+ const now = Date.now();
22
+ let html: string;
23
+ if (view === 'cards') {
24
+ const items = await upcomingEvents(repo, settings, now, { calendarIds: cal ? [cal.id] : undefined, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
25
+ html = `<section class="pc-ev-cards" data-layout="grid">${items.map((o) => eventCardHtml(o, settings, { showImage: true })).join('') || '<p class="pc-ev-empty">No upcoming events.</p>'}</section>`;
26
+ } else {
27
+ const { events, calendars } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', ...(cal ? { calendarIds: [cal.id] } : {}) });
28
+ html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: !cal && calendars.length > 1 });
29
+ }
30
+ Astro.response.headers.set('Content-Security-Policy', "frame-ancestors *");
31
+ ---
32
+
33
+ <Site title={cal?.name ?? 'Calendar'} bare noindex>
34
+ <base target="_top" />
35
+ <div class="pc-embed" set:html={html} />
36
+ <script is:inline>
37
+ (function () {
38
+ var send = function () { parent.postMessage({ acmsCalendarEmbed: true, height: document.documentElement.scrollHeight }, '*'); };
39
+ window.addEventListener('load', send);
40
+ if (window.ResizeObserver) new ResizeObserver(send).observe(document.documentElement);
41
+ })();
42
+ </script>
43
+ </Site>
@@ -0,0 +1,74 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import EventPage from '../../components/EventPage.astro';
4
+ import { getRepo } from '../../lib/repo';
5
+ import { expandDynamicBlocks } from '../../lib/dynamicBlocks';
6
+ import { highlightCodeHtml } from '../../lib/highlightCode';
7
+ import { validateSession, SESSION_COOKIE } from '../../lib/auth';
8
+ import { effectiveSettings } from '../../lib/designPreview';
9
+ import { resolveDesign } from '../../lib/theme';
10
+ import { siteOrigin } from '../../lib/seo';
11
+ import { CALENDAR, eventUrl } from '../../lib/calendar/config';
12
+ import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
13
+ import { eventJsonLd } from '../../lib/calendar/seo';
14
+ import { upcomingEvents } from '../../lib/calendar/cards';
15
+ import { createRsvp, seatsTaken, BookingError } from '../../lib/booking/service';
16
+ import { manageUrl } from '../../lib/calendar/config';
17
+ import { waitUntilFrom } from '../../lib/webhooks';
18
+
19
+ // Public event page. Drafts and private-calendar events 404 for visitors
20
+ // (admins see them with a banner). ?on=YYYY-MM-DD shows one occurrence of a
21
+ // repeating event — the calendar grid links carry it.
22
+ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
23
+ const repo = await getRepo(Astro.locals);
24
+ const event = await repo.getEventBySlug(Astro.params.slug ?? '');
25
+ const [rawSettings, allCalendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
26
+ const calMap = new Map(allCalendars.map((c) => [c.id, c]));
27
+ const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
28
+ if (!event || (!eventIsPublic(event, calMap) && !isAdmin)) return new Response(null, { status: 404, statusText: 'Not found' });
29
+
30
+ const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
31
+ const now = Date.now();
32
+ const on = Astro.url.searchParams.get('on') ?? undefined;
33
+ const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-\d{2}$/.test(on) ? on : undefined);
34
+ if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
35
+ const origin = siteOrigin(settings, Astro.url);
36
+ // RSVP: "Save my seat" posts back here and lands on the manage page
37
+ let rsvpError = '';
38
+ if (Astro.request.method === 'POST' && event.rsvp.enabled && event.status === 'published') {
39
+ const form = await Astro.request.formData();
40
+ try {
41
+ const b = await createRsvp(repo, {
42
+ event, start: occurrence.start, end: occurrence.end, seats: Number(form.get('seats')) || 1,
43
+ booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? '') },
44
+ }, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
45
+ return Astro.redirect(`${manageUrl(b.manageToken)}?new=1`);
46
+ } catch (err) {
47
+ rsvpError = err instanceof BookingError ? err.message : 'Couldn’t save your RSVP. Please try again.';
48
+ }
49
+ }
50
+ const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
51
+ const rsvp = event.rsvp.enabled
52
+ ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, open: occurrence.end > now && !event.cancelled }
53
+ : null;
54
+ const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
55
+ const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
56
+ const upcoming = (await upcomingEvents(repo, settings, now, { limit: 4 })).filter((u) => u.event.id !== event.id).slice(0, 3);
57
+ const pageUrl = origin + eventUrl(event.slug);
58
+ const jsonLd = eventJsonLd(event, occurrence, settings, origin, pageUrl);
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ const Template = resolveDesign(settings).theme.EventTemplate as any;
61
+ const props = { event, occurrence, settings, calendars, html, upcoming, origin, preview: event.status !== 'published', rsvp };
62
+ ---
63
+
64
+ <Site
65
+ title={event.seoTitle || event.title}
66
+ exactTitle={Boolean(event.seoTitle)}
67
+ description={event.seoDesc || event.excerpt || undefined}
68
+ image={event.cover || undefined}
69
+ imageAlt={event.coverAlt || undefined}
70
+ jsonLd={jsonLd}
71
+ noindex={event.status !== 'published'}
72
+ >
73
+ {Template ? <Template {...props} /> : <EventPage {...props} />}
74
+ </Site>
@@ -0,0 +1,25 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+ import { siteOrigin } from '../../lib/seo';
4
+ import { CALENDAR, eventUrl } from '../../lib/calendar/config';
5
+ import { eventIsPublic } from '../../lib/calendar/events';
6
+ import { buildFeed } from '../../lib/calendar/ics-write';
7
+ import { icsResponse } from '../../lib/calendar/feeds';
8
+
9
+ // Single-event .ics behind "Add to calendar → Apple / Download". Served
10
+ // inline so iPhones open it straight in Calendar.
11
+ export const GET: APIRoute = async ({ locals, url, params }) => {
12
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
13
+ const repo = await getRepo(locals);
14
+ const event = await repo.getEventBySlug(params.slug ?? '');
15
+ const calendars = await repo.listCalendars();
16
+ const map = new Map(calendars.map((c) => [c.id, c]));
17
+ if (!event || !eventIsPublic(event, map)) return new Response(null, { status: 404 });
18
+ const settings = await repo.getSettings();
19
+ const origin = siteOrigin(settings, url);
20
+ const body = buildFeed([event], settings.timezone, {
21
+ name: event.title, origin, host: new URL(origin).host, tz: settings.timezone || 'UTC',
22
+ eventUrl: (e) => eventUrl(e.slug), calendars: map, refreshMinutes: 1440,
23
+ });
24
+ return icsResponse(body, `${event.slug}.ics`);
25
+ };
@@ -1,8 +1,10 @@
1
1
  ---
2
+ import ThemeSections from '../components/ThemeSections.astro';
2
3
  import Site from '../layouts/Site.astro';
3
4
  import WidgetRenderer from '../components/WidgetRenderer.astro';
4
5
  import { getRepo } from '../lib/repo';
5
6
  import { resolveDesign } from '../lib/theme';
7
+ import { effectiveSettings } from '../lib/designPreview';
6
8
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
9
  import { I18N, localeFromPath } from '../lib/i18n';
8
10
 
@@ -11,10 +13,12 @@ const repo = await getRepo(Astro.locals);
11
13
  // resolved from the request path (injectRoute can't pass props)
12
14
  const locale = I18N.enabled ? localeFromPath(Astro.url.pathname) : I18N.defaultLocale;
13
15
  const wantsLocaleHome = I18N.enabled && locale !== I18N.defaultLocale;
14
- let [home, settings] = await Promise.all([
16
+ let [home, rawSettings] = await Promise.all([
15
17
  repo.getHomePage(wantsLocaleHome ? locale : undefined),
16
18
  repo.getSettings(),
17
19
  ]);
20
+ // ?designPreview=1 (admin-only) renders the staged Design-screen changes
21
+ const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
18
22
  // a locale home that isn't authored yet falls back to the default home —
19
23
  // /es must never 404 mid-setup
20
24
  if (wantsLocaleHome && !home) home = await repo.getHomePage();
@@ -23,7 +27,7 @@ if (wantsLocaleHome && !home) home = await repo.getHomePage();
23
27
  // isHome content page unless the Design screen forces 'content'. The template
24
28
  // receives the page (its body renders as the hero) plus published posts/tags.
25
29
  const design = resolveDesign(settings);
26
- const HomeTemplate = design.homeMode !== 'content' ? (design.HomeTemplate as any) : null;
30
+ const HomeTemplate = design.homeMode !== 'content' && (!home || settings.sectionPages?.[home.id]?.mode !== 'content') ? (design.HomeTemplate as any) : null;
27
31
  // theme homepages are blog-shaped — they receive the built-in collection only
28
32
  const [posts, tags] = HomeTemplate
29
33
  ? await Promise.all([repo.listPublishedPosts('posts'), repo.listTags()])
@@ -35,7 +39,9 @@ const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
35
39
  ---
36
40
 
37
41
  <Site title={home?.seoTitle || undefined} description={home?.seoDesc || undefined}>
38
- {HomeTemplate ? (
42
+ {home && settings.sectionPages?.[home.id]?.mode === 'sections' ? (
43
+ <ThemeSections page={home} settings={settings} />
44
+ ) : HomeTemplate ? (
39
45
  <HomeTemplate settings={settings} page={homeForTemplate} posts={posts} tags={tags} />
40
46
  ) : home ? (
41
47
  home.bodyHtml ? (
@@ -0,0 +1,4 @@
1
+ ---
2
+ import LegalPage from '../../components/LegalPage.astro';
3
+ ---
4
+ <LegalPage policy="privacy" />
@@ -0,0 +1,4 @@
1
+ ---
2
+ import LegalPage from '../../components/LegalPage.astro';
3
+ ---
4
+ <LegalPage policy="terms" />
@@ -1,9 +1,18 @@
1
1
  ---
2
2
  import Site from '../layouts/Site.astro';
3
3
  import { getRepo } from '../lib/repo';
4
+ import { cleanEventName } from '../lib/analytics';
5
+ import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
6
+ import { effectiveNav } from '../lib/designPreview';
4
7
 
5
8
  const repo = await getRepo(Astro.locals);
6
- const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
9
+ const [links, settings, liveNav] = await Promise.all([repo.listLinks(), repo.getSettings(), repo.listNavItems()]);
10
+ // staged social links show up in design preview mode
11
+ const navItems = await effectiveNav(liveNav, Astro.url, Astro.cookies, Astro.locals);
12
+ // the site's social links (Navigation → Social) as an icon row under the bio
13
+ const socials = navItems
14
+ .filter((n) => n.area === 'social')
15
+ .map((n) => ({ ...n, path: socialIconPath(n.url) }));
7
16
 
8
17
  // links page has its own identity; falls back to the site's until it's set
9
18
  const profileName = settings.linksName || settings.siteTitle;
@@ -11,6 +20,11 @@ const profileBio = settings.linksBio || settings.tagline;
11
20
 
12
21
  const SPACER_PX: Record<string, number> = { s: 16, m: 36, l: 72 };
13
22
 
23
+ // every link button is auto-stamped with an analytics event so per-link click
24
+ // counts (sponsors!) show up in Umami alongside the first-party /go counter
25
+ const linkEvent = (label: string): string | undefined =>
26
+ cleanEventName(`Link: ${label}`) === 'Link:' ? undefined : cleanEventName(`Link: ${label}`);
27
+
14
28
  // icon column convention: '<svg…' inline markup, http/​/ image URL,
15
29
  // 'favicon' derive from the link's host, anything else = emoji/text
16
30
  function iconInfo(l: (typeof links)[number]): { type: 'svg' | 'img' | 'emoji' | 'none'; value: string } {
@@ -65,8 +79,10 @@ function vcardHref(l: (typeof links)[number]): string {
65
79
  return `data:text/vcard;charset=utf-8,${encodeURIComponent(lines.join('\r\n'))}`;
66
80
  }
67
81
 
68
- // incomplete buttons (no destination yet) stay hidden on the public page
82
+ // incomplete buttons (no destination yet) and blocks switched off in the
83
+ // admin (meta.hidden) stay hidden on the public page
69
84
  const visible = links.filter((l) => {
85
+ if (l.meta?.hidden) return false;
70
86
  if (l.kind === 'link' || l.kind === 'email' || l.kind === 'phone') return Boolean(l.url.trim());
71
87
  if (l.kind === 'heading' || l.kind === 'text') return Boolean((l.kind === 'heading' ? l.label : l.html).trim());
72
88
  if (l.kind === 'html') return Boolean(l.html.trim());
@@ -79,6 +95,15 @@ const visible = links.filter((l) => {
79
95
  {settings.linksAvatar && <img class="links-avatar" src={settings.linksAvatar} alt={profileName} />}
80
96
  <h1>{profileName}</h1>
81
97
  {profileBio && <p class="tagline">{profileBio}</p>}
98
+ {socials.length > 0 && (
99
+ <nav class="social-nav" aria-label="Social">
100
+ {socials.map((n) => (
101
+ <a href={n.url} class:list={[{ 'has-icon': n.path }]} aria-label={n.path ? n.label : undefined} title={n.path ? n.label : undefined} target="_blank" rel="noopener me">
102
+ {n.path ? <Fragment set:html={socialIconSvg(n.path)} /> : n.label}
103
+ </a>
104
+ ))}
105
+ </nav>
106
+ )}
82
107
  {visible.map((l) => {
83
108
  const ico = iconInfo(l);
84
109
  const icon =
@@ -96,15 +121,16 @@ const visible = links.filter((l) => {
96
121
  case 'html':
97
122
  return <div class="link-html" set:html={l.html} />;
98
123
  case 'email':
99
- return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
124
+ return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
100
125
  case 'phone':
101
- return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
126
+ return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
102
127
  case 'vcard':
103
- return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
128
+ return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`} data-umami-event={linkEvent(l.label || 'Save contact')}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
104
129
  default:
105
- return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener">{icon}<span class="link-label">{l.label}</span></a>;
130
+ return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener" data-umami-event={linkEvent(l.label)}>{icon}<span class="link-label">{l.label}</span></a>;
106
131
  }
107
132
  })}
108
- {visible.length === 0 && <p>No links yet.</p>}
133
+ {visible.length === 0 && <p class="muted-note">No links yet.</p>}
134
+ <p class="links-foot"><a href="/">{settings.siteTitle}</a></p>
109
135
  </div>
110
136
  </Site>
@@ -35,6 +35,10 @@ export const GET: APIRoute = async ({ locals, url }) => {
35
35
  .map((p) => `- [${p.title}](${origin}${collectionUrl(key, p.slug)})${p.excerpt ? `: ${p.excerpt}` : ''}`),
36
36
  ]),
37
37
  '',
38
+ '## Agent editing',
39
+ '',
40
+ `- [Agent API documentation](${origin}/api/agent/docs) — authenticated HTTP and MCP access; generate a key in admin settings.`,
41
+ '',
38
42
  '## Feeds',
39
43
  '',
40
44
  `- [RSS](${origin}/rss.xml)`,
@@ -6,23 +6,46 @@ import { renderMarkdown } from '../../../lib/markdown';
6
6
  import { verifyPreviewToken } from '../../../lib/preview';
7
7
  import { expandDynamicBlocks } from '../../../lib/dynamicBlocks';
8
8
  import { highlightCodeHtml } from '../../../lib/highlightCode';
9
- import { verifySessionToken, SESSION_COOKIE } from '../../../lib/auth';
9
+ import { validateSession, SESSION_COOKIE } from '../../../lib/auth';
10
+ import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../../lib/drafts';
11
+ import EventPage from '../../../components/EventPage.astro';
12
+ import { featuredOccurrence } from '../../../lib/calendar/events';
13
+ import { siteOrigin } from '../../../lib/seo';
10
14
 
11
15
  // Draft preview, any status. Reachable by a logged-in admin (the editor's
12
- // preview iframe) or by anyone holding a signed share token.
16
+ // preview iframe) or by anyone holding a signed share token. Live documents
17
+ // with pending edits render the PENDING version — exactly what Update will
18
+ // publish — while the public URL keeps serving the live one.
13
19
  const { type, id } = Astro.params;
14
- if (type !== 'post' && type !== 'page') return new Response(null, { status: 404 });
20
+ if (type !== 'post' && type !== 'page' && type !== 'event') return new Response(null, { status: 404 });
15
21
 
16
- const isAdmin = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
22
+ const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
17
23
  const token = Astro.url.searchParams.get('token') ?? undefined;
18
24
  if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
19
25
  return new Response(null, { status: 404 });
20
26
  }
21
27
 
22
28
  const repo = await getRepo(Astro.locals);
23
- const post = type === 'post' ? await repo.getPost(id!) : null;
24
- const page = type === 'page' ? await repo.getPage(id!) : null;
25
- if (!post && !page) return new Response(null, { status: 404 });
29
+ const livePost = type === 'post' ? await repo.getPost(id!) : null;
30
+ const livePage = type === 'page' ? await repo.getPage(id!) : null;
31
+ const liveEvent = type === 'event' ? await repo.getEvent(id!) : null;
32
+ if (!livePost && !livePage && !liveEvent) return new Response(null, { status: 404 });
33
+ // events render through the real event page component, draft merged in
34
+ const event = liveEvent ? mergeEventDraft(liveEvent) : null;
35
+ const eventView = event
36
+ ? await (async () => {
37
+ const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
38
+ const occurrence = featuredOccurrence(event, settings, Date.now());
39
+ return {
40
+ settings, occurrence,
41
+ calendars: event.calendarIds.map((cid) => calendars.find((c) => c.id === cid)).filter((c): c is NonNullable<typeof c> => Boolean(c)),
42
+ html: highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo)),
43
+ origin: siteOrigin(settings, Astro.url),
44
+ };
45
+ })()
46
+ : null;
47
+ const post = livePost ? mergePostDraft(livePost) : null;
48
+ const page = livePage ? mergePageDraft(livePage) : null;
26
49
 
27
50
  const html = post ? highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo)) : '';
28
51
  const pageHtml = page?.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
@@ -30,7 +53,10 @@ const fmt = (ts: number | null) =>
30
53
  ts ? new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '';
31
54
  ---
32
55
 
33
- <Site title={post?.title ?? page?.seoTitle ?? page?.title}>
56
+ <Site title={post?.title ?? event?.title ?? page?.seoTitle ?? page?.title}>
57
+ {event && eventView?.occurrence && (
58
+ <EventPage event={event} occurrence={eventView.occurrence} settings={eventView.settings} calendars={eventView.calendars} html={eventView.html} upcoming={[]} origin={eventView.origin} preview />
59
+ )}
34
60
  {post && (
35
61
  <article>
36
62
  <h1>{post.title}</h1>