@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,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,4 +1,5 @@
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';
@@ -17,7 +18,7 @@ let [home, rawSettings] = await Promise.all([
17
18
  repo.getSettings(),
18
19
  ]);
19
20
  // ?designPreview=1 (admin-only) renders the staged Design-screen changes
20
- const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
21
+ const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
21
22
  // a locale home that isn't authored yet falls back to the default home —
22
23
  // /es must never 404 mid-setup
23
24
  if (wantsLocaleHome && !home) home = await repo.getHomePage();
@@ -26,7 +27,7 @@ if (wantsLocaleHome && !home) home = await repo.getHomePage();
26
27
  // isHome content page unless the Design screen forces 'content'. The template
27
28
  // receives the page (its body renders as the hero) plus published posts/tags.
28
29
  const design = resolveDesign(settings);
29
- 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;
30
31
  // theme homepages are blog-shaped — they receive the built-in collection only
31
32
  const [posts, tags] = HomeTemplate
32
33
  ? await Promise.all([repo.listPublishedPosts('posts'), repo.listTags()])
@@ -38,7 +39,9 @@ const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
38
39
  ---
39
40
 
40
41
  <Site title={home?.seoTitle || undefined} description={home?.seoDesc || undefined}>
41
- {HomeTemplate ? (
42
+ {home && settings.sectionPages?.[home.id]?.mode === 'sections' ? (
43
+ <ThemeSections page={home} settings={settings} />
44
+ ) : HomeTemplate ? (
42
45
  <HomeTemplate settings={settings} page={homeForTemplate} posts={posts} tags={tags} />
43
46
  ) : home ? (
44
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" />
@@ -2,9 +2,17 @@
2
2
  import Site from '../layouts/Site.astro';
3
3
  import { getRepo } from '../lib/repo';
4
4
  import { cleanEventName } from '../lib/analytics';
5
+ import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
6
+ import { effectiveNav } from '../lib/designPreview';
5
7
 
6
8
  const repo = await getRepo(Astro.locals);
7
- 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) }));
8
16
 
9
17
  // links page has its own identity; falls back to the site's until it's set
10
18
  const profileName = settings.linksName || settings.siteTitle;
@@ -71,8 +79,10 @@ function vcardHref(l: (typeof links)[number]): string {
71
79
  return `data:text/vcard;charset=utf-8,${encodeURIComponent(lines.join('\r\n'))}`;
72
80
  }
73
81
 
74
- // 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
75
84
  const visible = links.filter((l) => {
85
+ if (l.meta?.hidden) return false;
76
86
  if (l.kind === 'link' || l.kind === 'email' || l.kind === 'phone') return Boolean(l.url.trim());
77
87
  if (l.kind === 'heading' || l.kind === 'text') return Boolean((l.kind === 'heading' ? l.label : l.html).trim());
78
88
  if (l.kind === 'html') return Boolean(l.html.trim());
@@ -85,6 +95,15 @@ const visible = links.filter((l) => {
85
95
  {settings.linksAvatar && <img class="links-avatar" src={settings.linksAvatar} alt={profileName} />}
86
96
  <h1>{profileName}</h1>
87
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
+ )}
88
107
  {visible.map((l) => {
89
108
  const ico = iconInfo(l);
90
109
  const icon =
@@ -111,6 +130,7 @@ const visible = links.filter((l) => {
111
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>;
112
131
  }
113
132
  })}
114
- {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>
115
135
  </div>
116
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,17 +6,20 @@ 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';
10
- import { mergePageDraft, mergePostDraft } from '../../../lib/drafts';
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';
11
14
 
12
15
  // Draft preview, any status. Reachable by a logged-in admin (the editor's
13
16
  // preview iframe) or by anyone holding a signed share token. Live documents
14
17
  // with pending edits render the PENDING version — exactly what Update will
15
18
  // publish — while the public URL keeps serving the live one.
16
19
  const { type, id } = Astro.params;
17
- 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 });
18
21
 
19
- const isAdmin = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
22
+ const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
20
23
  const token = Astro.url.searchParams.get('token') ?? undefined;
21
24
  if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
22
25
  return new Response(null, { status: 404 });
@@ -25,7 +28,22 @@ if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
25
28
  const repo = await getRepo(Astro.locals);
26
29
  const livePost = type === 'post' ? await repo.getPost(id!) : null;
27
30
  const livePage = type === 'page' ? await repo.getPage(id!) : null;
28
- if (!livePost && !livePage) return new Response(null, { status: 404 });
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;
29
47
  const post = livePost ? mergePostDraft(livePost) : null;
30
48
  const page = livePage ? mergePageDraft(livePage) : null;
31
49
 
@@ -35,7 +53,10 @@ const fmt = (ts: number | null) =>
35
53
  ts ? new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '';
36
54
  ---
37
55
 
38
- <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
+ )}
39
60
  {post && (
40
61
  <article>
41
62
  <h1>{post.title}</h1>
@@ -6,6 +6,9 @@ import { collectionUrl, tagUrl } from '../lib/content';
6
6
  import { authorSlug } from '../lib/schema';
7
7
  import { I18N, localeFromPath, localeHomeUrl, normalizePageLocale } from '../lib/i18n';
8
8
  import { searchStrings } from '../lib/search-i18n';
9
+ import { CALENDAR, eventUrl } from '../lib/calendar/config';
10
+ import { eventIsPublic, featuredOccurrence, formatOccurrence } from '../lib/calendar/events';
11
+ import { siteLocale } from '../lib/dates';
9
12
 
10
13
  // The no-JS search surface (the modal's form falls back here). Serves both
11
14
  // /search and the locale-prefixed /<locale>/search routes — the locale comes
@@ -20,8 +23,10 @@ const locale = normalizePageLocale(reqLocale);
20
23
  const t = searchStrings((reqLocale || siteLang(settings)).slice(0, 2));
21
24
  const results = q
22
25
  ? await repo.searchPublished(q, { locale })
23
- : { posts: [], pages: [], tags: [], authors: [] };
24
- const total = results.posts.length + results.pages.length + results.tags.length + results.authors.length;
26
+ : { posts: [], pages: [], events: [], tags: [], authors: [] };
27
+ const calMap = new Map((await repo.listCalendars()).map((c) => [c.id, c]));
28
+ const events = CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsPublic(doc, calMap)).slice(0, 10) : [];
29
+ const total = results.posts.length + results.pages.length + events.length + results.tags.length + results.authors.length;
25
30
  ---
26
31
 
27
32
  <Site title={q ? `${t.label}: ${q}` : t.label} description={`${t.label} · ${settings.siteTitle}`} noindex={Boolean(q)}>
@@ -88,6 +93,26 @@ const total = results.posts.length + results.pages.length + results.tags.length
88
93
  </section>
89
94
  )}
90
95
 
96
+ {events.length > 0 && (
97
+ <section class="search-section">
98
+ <h2>{t.events}</h2>
99
+ <ul class="post-list">
100
+ {events.map(({ doc: e, snippet }) => {
101
+ const o = featuredOccurrence(e, settings, Date.now());
102
+ return (
103
+ <li class="post-item">
104
+ <div>
105
+ <a href={eventUrl(e.slug)}>{e.title}</a>
106
+ {o && <span class="search-loc">{formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range}</span>}
107
+ {snippet && <p class="post-excerpt" set:html={snippet} />}
108
+ </div>
109
+ </li>
110
+ );
111
+ })}
112
+ </ul>
113
+ </section>
114
+ )}
115
+
91
116
  {results.tags.length > 0 && (
92
117
  <section class="search-section">
93
118
  <h2>{t.tags}</h2>
@@ -118,31 +143,26 @@ const total = results.posts.length + results.pages.length + results.tags.length
118
143
  </Site>
119
144
 
120
145
  <style>
121
- .search-hero { margin: 24px 0 8px; }
122
- .search-form { display: flex; gap: 10px; margin: 18px 0; }
123
- .search-form input {
124
- flex: 1; font: inherit; padding: 10px 14px; border: 1px solid var(--line);
125
- border-radius: 9px; background: var(--panel); color: var(--ink);
126
- }
127
- .search-form input:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }
146
+ .search-hero { padding: calc(20px * var(--space)) 0 0; }
147
+ .search-hero h1 { font-size: clamp(34px, 5vw, 46px); margin: 0 0 18px; }
148
+ .search-form { display: flex; gap: 6px; padding: 5px; margin: 0 0 18px; background: var(--panel); border: 1px solid var(--line-2); border-radius: 14px; box-shadow: var(--sh-1); }
149
+ .search-form:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
150
+ .search-form input { flex: 1; min-width: 0; font: inherit; padding: 0 12px; border: 0; background: none; color: var(--ink); outline: none; }
128
151
  .search-form button {
129
- font: inherit; font-weight: 600; padding: 10px 20px; border: 0; border-radius: 9px;
130
- background: var(--accent); color: #fff; cursor: pointer;
152
+ font: 550 15px/1 var(--font-body); height: 42px; padding: 0 18px; border: 0; border-radius: 10px;
153
+ background: var(--btn); color: var(--btn-ink); cursor: pointer;
131
154
  }
132
- .search-count { color: var(--muted); }
133
- .search-section h2 { font-size: 1.1rem; margin-top: 2rem; }
155
+ .search-count { color: var(--muted); font-size: 14.5px; margin: 0 0 8px; }
156
+ .search-section h2 { font-size: 13px; font-weight: 550; letter-spacing: 0; color: var(--muted); margin: 36px 0 4px; font-family: var(--font-body); }
157
+ .post-list :global(.post-excerpt) { color: var(--muted); font-size: 15px; margin: 4px 0 0; }
134
158
  /* snippets arrive pre-escaped with <mark> around matches (set:html), so
135
159
  the highlight style must reach into the injected fragment */
136
160
  .post-excerpt :global(mark) {
137
161
  background: color-mix(in srgb, var(--accent) 22%, transparent);
138
162
  color: inherit; border-radius: 3px; padding: 0 1px;
139
163
  }
140
- .search-loc {
141
- font-size: 11px; font-weight: 700; letter-spacing: 0.06em; color: var(--muted);
142
- border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; margin-left: 8px;
143
- vertical-align: 2px;
144
- }
145
- .search-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
146
- .search-tag-count { color: var(--muted); }
164
+ .search-loc { font-size: 12.5px; font-weight: 550; color: var(--muted); margin-left: 10px; }
165
+ .search-tags { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; }
166
+ .search-tag-count { color: var(--muted); font-weight: 400; }
147
167
  .search-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; flex: none; }
148
168
  </style>