@acms-subir/core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/CHANGELOG.md +87 -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/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -1,4 +1,9 @@
1
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance } from '../schema';
1
+ import type { LiveStore } from '../live/store';
2
+ import type { BookingRequest } from '../backup/content';
3
+ import type { EmailStore } from '../email/store';
4
+ export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
5
+
6
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
2
7
 
3
8
  export interface Stats {
4
9
  publishedPages: number;
@@ -16,13 +21,33 @@ export interface SearchHit<T> {
16
21
  export interface SearchResults {
17
22
  posts: SearchHit<Post>[];
18
23
  pages: SearchHit<Page>[];
24
+ // published events; callers still apply calendar visibility (a private
25
+ // calendar's event must not surface publicly)
26
+ events: SearchHit<CalEvent>[];
19
27
  tags: { tag: Tag; count: number }[];
20
28
  authors: User[];
21
29
  }
22
30
 
31
+ export type EventUpdate = Partial<
32
+ Pick<
33
+ CalEvent,
34
+ | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
35
+ | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
36
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
37
+ | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp'
38
+ | 'publishedAt' | 'scheduledAt'
39
+ >
40
+ >;
41
+
23
42
  // The single contract both backends implement. The app only ever imports
24
43
  // this — never a concrete adapter.
25
44
  export interface ContentRepository {
45
+ readonly email: EmailStore;
46
+ readonly live: LiveStore;
47
+ // Bounded public catalog; fetch limit + 1 IDs without loading every body.
48
+ listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
49
+ exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
50
+ getBookingRequest(keyHash: string): Promise<{ requestHash: string; bookingId: string } | null>;
26
51
  // pages
27
52
  listPages(): Promise<Page[]>;
28
53
  getPage(id: string): Promise<Page | null>;
@@ -71,9 +96,87 @@ export interface ContentRepository {
71
96
  applyPostDraft(id: string): Promise<void>;
72
97
  discardPostDraft(id: string): Promise<void>;
73
98
  deletePost(id: string): Promise<void>;
74
- // flips scheduled posts whose time has passed to published; returns count
99
+ // flips scheduled posts AND events whose time has passed to published;
100
+ // returns the combined count
75
101
  publishDuePosts(now: number): Promise<number>;
76
102
 
103
+ // calendars — native calendars group events; feed calendars mirror an ICS
104
+ // URL live (their events are never stored). Deleting a calendar removes it
105
+ // from every event's calendarIds; the events themselves stay.
106
+ listCalendars(): Promise<Calendar[]>;
107
+ getCalendar(id: string): Promise<Calendar | null>;
108
+ getCalendarBySlug(slug: string): Promise<Calendar | null>;
109
+ createCalendar(data: Partial<Pick<Calendar, 'color' | 'description' | 'kind' | 'feedUrl' | 'visibility'>> & { name: string; slug: string }): Promise<Calendar>;
110
+ updateCalendar(id: string, data: Partial<Pick<Calendar, 'name' | 'slug' | 'color' | 'description' | 'feedUrl' | 'visibility' | 'feedToken'>>): Promise<void>;
111
+ reorderCalendars(ids: string[]): Promise<void>;
112
+ deleteCalendar(id: string): Promise<void>;
113
+
114
+ // events — same draft/publish contract as posts (live events stage edits
115
+ // until Update). startAt/endAt are derived from the wall-clock fields and
116
+ // the event (or site) timezone on every write.
117
+ listEvents(): Promise<CalEvent[]>;
118
+ listPublishedEvents(): Promise<CalEvent[]>;
119
+ getEvent(id: string): Promise<CalEvent | null>;
120
+ getEventBySlug(slug: string): Promise<CalEvent | null>;
121
+ createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
122
+ updateEvent(id: string, data: EventUpdate): Promise<void>;
123
+ saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
124
+ applyEventDraft(id: string): Promise<void>;
125
+ discardEventDraft(id: string): Promise<void>;
126
+ deleteEvent(id: string): Promise<void>;
127
+
128
+ // resources (rooms, people, vans…) and their booking links. Deleting a
129
+ // resource deletes its links; its bookings stay as history.
130
+ listResources(): Promise<Resource[]>;
131
+ getResource(id: string): Promise<Resource | null>;
132
+ getResourceBySlug(slug: string): Promise<Resource | null>;
133
+ createResource(data: Partial<Omit<Resource, 'id' | 'createdAt' | 'updatedAt' | 'order' | 'feedToken'>> & { name: string; slug: string }): Promise<Resource>;
134
+ updateResource(id: string, data: Partial<Omit<Resource, 'id' | 'createdAt' | 'updatedAt' | 'order'>>): Promise<void>;
135
+ reorderResources(ids: string[]): Promise<void>;
136
+ deleteResource(id: string): Promise<void>;
137
+ listBookingLinks(resourceId?: string): Promise<BookingLink[]>;
138
+ getBookingLink(id: string): Promise<BookingLink | null>;
139
+ getBookingLinkBySlug(resourceId: string, slug: string): Promise<BookingLink | null>;
140
+ createBookingLink(data: Partial<Omit<BookingLink, 'id' | 'createdAt' | 'updatedAt' | 'order'>> & { resourceId: string; name: string; slug: string }): Promise<BookingLink>;
141
+ updateBookingLink(id: string, data: Partial<Omit<BookingLink, 'id' | 'resourceId' | 'createdAt' | 'updatedAt'>>): Promise<void>;
142
+ deleteBookingLink(id: string): Promise<void>;
143
+
144
+ // bookings (+ staff blocks and event RSVPs). createBooking is the only
145
+ // write that can fail on a race: it inserts atomically only if the held
146
+ // range is still free (resource bookings) or seats remain (RSVPs), and
147
+ // returns null when it lost.
148
+ listBookings(filter?: { resourceId?: string; eventId?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
149
+ getBooking(id: string): Promise<Booking | null>;
150
+ getBookingByToken(token: string): Promise<Booking | null>;
151
+ createBooking(
152
+ data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'>,
153
+ guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
154
+ ): Promise<Booking | null>;
155
+ // moves a booking only if the new held range is free (reschedule)
156
+ moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
157
+ updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string }): Promise<void>;
158
+ deleteBooking(id: string): Promise<void>;
159
+ // confirmed bookings starting in [from, to) that haven't had a reminder
160
+ listDueReminders(from: number, to: number): Promise<Booking[]>;
161
+
162
+ // scoped API keys — only the SHA-256 hash is stored; findApiKeyByHash
163
+ // also stamps lastUsedAt
164
+ listApiKeys(): Promise<ApiKey[]>;
165
+ createApiKey(data: { name: string; prefix: string; keyHash: string; scopes: ApiScope[] }): Promise<ApiKey>;
166
+ findApiKeyByHash(keyHash: string): Promise<ApiKey | null>;
167
+ deleteApiKey(id: string): Promise<void>;
168
+
169
+ // webhooks + their delivery queue (lib/webhooks.ts drives both)
170
+ listWebhooks(): Promise<Webhook[]>;
171
+ createWebhook(data: { url: string; secret: string; events: string[] }): Promise<Webhook>;
172
+ updateWebhook(id: string, data: Partial<Pick<Webhook, 'url' | 'events' | 'active' | 'secret'>>): Promise<void>;
173
+ deleteWebhook(id: string): Promise<void>;
174
+ createDeliveries(rows: Omit<WebhookDelivery, 'attempts' | 'responseCode' | 'deliveredAt'>[]): Promise<void>;
175
+ listDueDeliveries(now: number, limit: number): Promise<WebhookDelivery[]>;
176
+ listDeliveries(webhookId: string, limit: number): Promise<WebhookDelivery[]>;
177
+ claimDelivery(id: string, now: number): Promise<WebhookDelivery | null>;
178
+ recordDelivery(id: string, data: Pick<WebhookDelivery, 'status' | 'attempts' | 'nextAt' | 'responseCode' | 'deliveredAt'>): Promise<void>;
179
+
77
180
  // media
78
181
  listMedia(): Promise<Media[]>;
79
182
  getMedia(id: string): Promise<Media | null>;
@@ -119,7 +222,7 @@ export interface ContentRepository {
119
222
  createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
120
223
  updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
121
224
  deleteUser(id: string): Promise<void>;
122
- getUserAuth(email: string): Promise<{ user: User; passHash: string } | null>;
225
+ getUserAuth(email: string): Promise<{ user: User; passHash: string; sessionVersion: number } | null>;
123
226
 
124
227
  // navigation — managed header/footer/social items; empty header falls back
125
228
  // to the legacy auto-list of published pages in Site.astro
@@ -148,6 +251,13 @@ export interface ContentRepository {
148
251
  navItems: NavItem[];
149
252
  users: User[];
150
253
  redirects: Redirect[];
254
+ // added with the calendar module; older backups restore with none
255
+ calendars?: Calendar[];
256
+ events?: CalEvent[];
257
+ resources?: Resource[];
258
+ bookingLinks?: BookingLink[];
259
+ bookings?: Booking[];
260
+ bookingRequests?: BookingRequest[];
151
261
  }): Promise<void>;
152
262
 
153
263
  // redirects — exact-path forwards, consulted when a request would 404.
@@ -161,8 +271,18 @@ export interface ContentRepository {
161
271
  updateSettings(data: Partial<Settings>): Promise<void>;
162
272
  // Design-screen staging: save stages into settings.draftDesign, apply
163
273
  // copies staged → live design fields and clears, discard just clears
164
- saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover'>>): Promise<void>;
165
- applyDesignDraft(): Promise<void>;
274
+ saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
275
+ navItems?: { id: string; label: string; url: string; area: NavItem['area'] }[];
276
+ shareEpoch?: string;
277
+ }): Promise<void>;
278
+ // replace the whole staged draft (null clears it) — per-change undo, restore
279
+ setDesignDraft(draft: Record<string, unknown> | null, expectedRevision?: number, actor?: string): Promise<void>;
280
+ listDesignAudit(): Promise<Record<string, unknown>[]>;
281
+ // publish: snapshot the outgoing live design into design_versions (last 20
282
+ // kept), apply the draft's settings + staged navigation, clear the draft
283
+ applyDesignDraft(opts?: { author?: string; summary?: string; expectedRevision?: number }): Promise<void>;
166
284
  discardDesignDraft(): Promise<void>;
285
+ listDesignVersions(limit?: number): Promise<DesignVersion[]>;
286
+ getDesignVersion(id: string): Promise<DesignVersion | null>;
167
287
  getStats(): Promise<Stats>;
168
288
  }
@@ -0,0 +1,27 @@
1
+ import { syncDueLive } from './live/service';
2
+ import type { ContentRepository } from './repo/types';
3
+ import { processDueDeliveries } from './webhooks';
4
+ import { dispatchEmail } from './email/service';
5
+ import { sendDueReminders } from './booking/service';
6
+
7
+ // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
8
+ // this heartbeat: monitoring must prove the independent scheduler ran.
9
+ export async function runScheduledTasks(repo: ContentRepository, locals?: unknown) {
10
+ const startedAt = Date.now();
11
+ const settings = await repo.getSettings();
12
+ const jobs = {
13
+ publish: () => repo.publishDuePosts(startedAt),
14
+ webhooks: () => processDueDeliveries(repo),
15
+ reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
16
+ email: () => dispatchEmail(repo, locals, 10),
17
+ live: () => syncDueLive(repo, locals),
18
+ };
19
+ const results: Record<string, { ok: boolean; count?: number }> = {};
20
+ for (const [name, run] of Object.entries(jobs)) {
21
+ try { results[name] = { ok: true, count: await run() }; }
22
+ catch { results[name] = { ok: false }; }
23
+ }
24
+ const report = { startedAt, finishedAt: Date.now(), ok: Object.values(results).every(r => r.ok), jobs: results };
25
+ await repo.email.recordScheduler(report);
26
+ return report;
27
+ }
package/src/lib/schema.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { LiveConfig } from './live/types';
2
+ import { SectionPages } from '../theme-sdk/contract.mjs';
3
+ import { AiConfig, AgentConfig } from './ai-schema';
1
4
  import { z } from 'zod';
2
5
 
3
6
  // ---- Widgets -------------------------------------------------------------
@@ -6,6 +9,7 @@ import { z } from 'zod';
6
9
  // component in src/components/widgets/ + an editor section in the admin.
7
10
 
8
11
  export const widgetConfigs = {
12
+ live: z.object({ display: z.enum(['card','player']).default('card') }),
9
13
  hero: z.object({
10
14
  heading: z.string().default(''),
11
15
  sub: z.string().default(''),
@@ -193,7 +197,7 @@ export const authorSlug = (u: Pick<User, 'name' | 'slug'>) => u.slug || slugify(
193
197
  // `<svg…>` markup (brand icons), or the keyword 'favicon'. `meta` is per-kind
194
198
  // extras: { color, textColor } button colors, { size } spacer height, vcard
195
199
  // fields, etc.
196
- export const LinkKind = z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html']);
200
+ export const LinkKind = z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html', 'live']);
197
201
  export type LinkKind = z.infer<typeof LinkKind>;
198
202
 
199
203
  export const Link = z.object({
@@ -236,6 +240,312 @@ export const Redirect = z.object({
236
240
  });
237
241
  export type Redirect = z.infer<typeof Redirect>;
238
242
 
243
+ // ---- Calendar ------------------------------------------------------------
244
+ // Calendars group events. `native` calendars hold events authored in the
245
+ // admin; `feed` calendars mirror an external ICS feed (Google, Apple,
246
+ // Outlook, any .ics) live, read-only. Visibility decides where a calendar's
247
+ // events surface: `public` = listed on the calendar page + the all-calendars
248
+ // feed; `unlisted` = event pages and its own feed work, but it's not listed;
249
+ // `private` = admin-only, plus a secret-token feed for staff subscriptions.
250
+ export const CalendarKind = z.enum(['native', 'feed']);
251
+ export type CalendarKind = z.infer<typeof CalendarKind>;
252
+ export const CalendarVisibility = z.enum(['public', 'unlisted', 'private']);
253
+ export type CalendarVisibility = z.infer<typeof CalendarVisibility>;
254
+
255
+ export const Calendar = z.object({
256
+ id: z.string(),
257
+ slug: z.string(),
258
+ name: z.string(),
259
+ color: z.string().default('#4f46e5'),
260
+ description: z.string().default(''),
261
+ kind: CalendarKind.default('native'),
262
+ // feed calendars only: the normalized ICS URL
263
+ feedUrl: z.string().default(''),
264
+ visibility: CalendarVisibility.default('public'),
265
+ // secret for /calendar/<slug>.ics?t=… on private calendars (rotatable)
266
+ feedToken: z.string().default(''),
267
+ order: z.number().default(0),
268
+ createdAt: z.number(),
269
+ updatedAt: z.number(),
270
+ });
271
+ export type Calendar = z.infer<typeof Calendar>;
272
+
273
+ // Times are stored as WALL-CLOCK strings in the event's timezone — that's
274
+ // what recurrence is defined against (a 10am weekly service stays at 10am
275
+ // across DST). Timed: 'YYYY-MM-DDTHH:mm'. All-day: 'YYYY-MM-DD', with `end`
276
+ // the INCLUSIVE last day (how people think about it; ICS output converts to
277
+ // the exclusive form). `timezone` '' = the site timezone. startAt/endAt are
278
+ // the derived UTC instants of the first occurrence, kept for sorting.
279
+ export const EventLocation = z.object({
280
+ name: z.string().default(''),
281
+ address: z.string().default(''),
282
+ // a map link or an online-meeting URL
283
+ url: z.string().default(''),
284
+ });
285
+ export type EventLocation = z.infer<typeof EventLocation>;
286
+
287
+ export const EventCta = z.object({
288
+ label: z.string().default(''),
289
+ href: z.string().default(''),
290
+ style: z.enum(['primary', 'outline']).default('primary'),
291
+ });
292
+ export type EventCta = z.infer<typeof EventCta>;
293
+
294
+ export const CalEvent = z.object({
295
+ id: z.string(),
296
+ slug: z.string(),
297
+ title: z.string(),
298
+ bodyJson: z.string().default(''),
299
+ bodyHtml: z.string().default(''),
300
+ excerpt: z.string().default(''),
301
+ cover: z.string().default(''),
302
+ coverAlt: z.string().default(''),
303
+ seoTitle: z.string().default(''),
304
+ seoDesc: z.string().default(''),
305
+ status: Status,
306
+ featured: z.boolean().default(false),
307
+ tags: z.array(z.string()).default([]),
308
+ template: z.string().default(''),
309
+ start: z.string(),
310
+ end: z.string().default(''),
311
+ allDay: z.boolean().default(false),
312
+ timezone: z.string().default(''),
313
+ // RFC 5545 RRULE body without the "RRULE:" prefix; '' = one-off
314
+ rrule: z.string().default(''),
315
+ // wall-clock starts (same format as `start`) of skipped occurrences
316
+ exdates: z.array(z.string()).default([]),
317
+ // a cancelled event stays visible (struck through) so people aren't left
318
+ // wondering where it went; feeds emit STATUS:CANCELLED
319
+ cancelled: z.boolean().default(false),
320
+ location: EventLocation.default({}),
321
+ cta: EventCta.default({}),
322
+ calendarIds: z.array(z.string()).default([]),
323
+ // values for acms({ calendar.eventFields }) custom fields, keyed by field key
324
+ fields: z.record(z.string()).default({}),
325
+ // UID of the feed event this was imported from ('' = authored here)
326
+ sourceUid: z.string().default(''),
327
+ // RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
328
+ // once; RSVPs are stored as bookings of kind 'rsvp'
329
+ rsvp: z.object({
330
+ enabled: z.boolean().default(false),
331
+ capacity: z.number().int().default(0),
332
+ autoConfirm: z.boolean().default(true),
333
+ maxSeats: z.number().int().default(4),
334
+ }).default({}),
335
+ startAt: z.number(),
336
+ endAt: z.number(),
337
+ draft: z.record(z.any()).nullable().default(null),
338
+ publishedAt: z.number().nullable().default(null),
339
+ scheduledAt: z.number().nullable().default(null),
340
+ createdAt: z.number(),
341
+ updatedAt: z.number(),
342
+ });
343
+ export type CalEvent = z.infer<typeof CalEvent>;
344
+
345
+ // ---- Bookings --------------------------------------------------------------
346
+ // A Resource is anything people reserve: a room, a person's time, a van. Its
347
+ // weekly hours + date overrides are the outer bound; each BookingLink narrows
348
+ // them and adds its own rules (length, buffers, notice, questions, whether it
349
+ // confirms automatically). Every link of a resource subtracts the same
350
+ // Bookings, so links can never double-book the resource. Times: weekly hours
351
+ // and overrides are wall-clock in the resource's zone; bookings store UTC.
352
+
353
+ export const TimeRange = z.object({
354
+ start: z.string().regex(/^\d{2}:\d{2}$/),
355
+ end: z.string().regex(/^\d{2}:\d{2}$/),
356
+ });
357
+ export type TimeRange = z.infer<typeof TimeRange>;
358
+
359
+ export const WEEKDAYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const;
360
+ export type Weekday = (typeof WEEKDAYS)[number];
361
+ export const WeeklyHours = z.object({
362
+ su: z.array(TimeRange).default([]),
363
+ mo: z.array(TimeRange).default([]),
364
+ tu: z.array(TimeRange).default([]),
365
+ we: z.array(TimeRange).default([]),
366
+ th: z.array(TimeRange).default([]),
367
+ fr: z.array(TimeRange).default([]),
368
+ sa: z.array(TimeRange).default([]),
369
+ });
370
+ export type WeeklyHours = z.infer<typeof WeeklyHours>;
371
+
372
+ // a date (or inclusive date span) with replacement hours; [] = closed
373
+ export const DateOverride = z.object({
374
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
375
+ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(''),
376
+ ranges: z.array(TimeRange).default([]),
377
+ label: z.string().default(''),
378
+ });
379
+ export type DateOverride = z.infer<typeof DateOverride>;
380
+
381
+ export const Resource = z.object({
382
+ id: z.string(),
383
+ slug: z.string(),
384
+ name: z.string(),
385
+ // a free label: Room, Person, Vehicle, Equipment… (acms({ calendar.resourceKinds }) suggests values)
386
+ kind: z.string().default('Room'),
387
+ description: z.string().default(''),
388
+ photo: z.string().default(''),
389
+ capacity: z.number().int().nullable().default(null),
390
+ amenities: z.array(z.string()).default([]),
391
+ timezone: z.string().default(''),
392
+ hours: WeeklyHours.default({}),
393
+ overrides: z.array(DateOverride).default([]),
394
+ // external ICS feeds whose events block time (a person's Google calendar)
395
+ busyFeeds: z.array(z.string()).default([]),
396
+ notifyEmails: z.array(z.string()).default([]),
397
+ // public = listed in /book; unlisted = reachable by link only
398
+ visibility: z.enum(['public', 'unlisted']).default('public'),
399
+ // confirmed bookings show on the public calendar as "Reserved" blocks
400
+ showOnCalendar: z.boolean().default(false),
401
+ // secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
402
+ feedToken: z.string().default(''),
403
+ order: z.number().default(0),
404
+ createdAt: z.number(),
405
+ updatedAt: z.number(),
406
+ });
407
+ export type Resource = z.infer<typeof Resource>;
408
+
409
+ export const QUESTION_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date'] as const;
410
+ export const BookingQuestion = z.object({
411
+ id: z.string(),
412
+ label: z.string(),
413
+ type: z.enum(QUESTION_TYPES).default('text'),
414
+ required: z.boolean().default(false),
415
+ options: z.array(z.string()).default([]),
416
+ help: z.string().default(''),
417
+ });
418
+ export type BookingQuestion = z.infer<typeof BookingQuestion>;
419
+
420
+ export const BookingMode = z.enum(['slots', 'range', 'day']);
421
+ export type BookingMode = z.infer<typeof BookingMode>;
422
+
423
+ export const BookingLink = z.object({
424
+ id: z.string(),
425
+ resourceId: z.string(),
426
+ slug: z.string(),
427
+ name: z.string(),
428
+ description: z.string().default(''),
429
+ // slots: fixed length; range: choose a length from `lengths`; day: whole days
430
+ mode: BookingMode.default('slots'),
431
+ duration: z.number().int().default(60), // minutes (slots)
432
+ lengths: z.array(z.number().int()).default([]), // minutes (range) — chips
433
+ minDays: z.number().int().default(1), // day mode
434
+ maxDays: z.number().int().default(1),
435
+ step: z.number().int().default(30), // minutes between start times
436
+ bufferBefore: z.number().int().default(0),
437
+ bufferAfter: z.number().int().default(0),
438
+ minNotice: z.number().int().default(120), // minutes
439
+ maxAdvance: z.number().int().default(90), // days
440
+ perDayCap: z.number().int().default(0), // 0 = unlimited
441
+ // narrows the resource's hours; null = use the resource's hours as-is
442
+ hours: WeeklyHours.nullable().default(null),
443
+ // confirmed at once (and on the calendar) vs. held as pending for approval
444
+ autoConfirm: z.boolean().default(true),
445
+ visibility: z.enum(['public', 'unlisted']).default('public'),
446
+ askPhone: z.boolean().default(false),
447
+ questions: z.array(BookingQuestion).default([]),
448
+ confirmMessage: z.string().default(''),
449
+ redirectUrl: z.string().default(''),
450
+ active: z.boolean().default(true),
451
+ order: z.number().default(0),
452
+ createdAt: z.number(),
453
+ updatedAt: z.number(),
454
+ });
455
+ export type BookingLink = z.infer<typeof BookingLink>;
456
+
457
+ export const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled']);
458
+ export type BookingStatus = z.infer<typeof BookingStatus>;
459
+
460
+ export const Booking = z.object({
461
+ id: z.string(),
462
+ // booking = someone reserved it; block = staff closed the time; rsvp = a
463
+ // seat at an event occurrence (resourceId/linkId empty, eventId set)
464
+ kind: z.enum(['booking', 'block', 'rsvp']).default('booking'),
465
+ resourceId: z.string().default(''),
466
+ linkId: z.string().default(''),
467
+ eventId: z.string().default(''),
468
+ status: BookingStatus,
469
+ start: z.number(),
470
+ end: z.number(),
471
+ // start/end widened by the link's buffers — what actually blocks the resource
472
+ holdStart: z.number(),
473
+ holdEnd: z.number(),
474
+ timezone: z.string().default(''),
475
+ name: z.string().default(''),
476
+ email: z.string().default(''),
477
+ phone: z.string().default(''),
478
+ seats: z.number().int().default(1),
479
+ answers: z.record(z.string()).default({}),
480
+ notes: z.string().default(''), // staff-only
481
+ manageToken: z.string().default(''),
482
+ source: z.enum(['web', 'admin', 'api', 'agent']).default('web'),
483
+ reminderSentAt: z.number().nullable().default(null),
484
+ notificationVersion: z.number().default(0),
485
+ createdAt: z.number(),
486
+ updatedAt: z.number(),
487
+ });
488
+ export type Booking = z.infer<typeof Booking>;
489
+
490
+ // ---- Integrations: scoped API keys + signed webhooks ----------------------
491
+ // Keys are shown once at creation; only a SHA-256 hash is stored. The agent
492
+ // key from Settings → AI & agents keeps working and counts as every scope.
493
+ export const API_SCOPES = [
494
+ 'events:read', 'events:write',
495
+ 'bookings:read', 'bookings:write',
496
+ 'resources:read', 'resources:write',
497
+ 'design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write',
498
+ ] as const;
499
+ export const ApiScope = z.enum(API_SCOPES);
500
+ export type ApiScope = z.infer<typeof ApiScope>;
501
+
502
+ export const ApiKey = z.object({
503
+ id: z.string(),
504
+ name: z.string(),
505
+ // first characters of the key, shown in lists so owners can tell keys apart
506
+ prefix: z.string(),
507
+ scopes: z.array(ApiScope).default([]),
508
+ lastUsedAt: z.number().nullable().default(null),
509
+ createdAt: z.number(),
510
+ });
511
+ export type ApiKey = z.infer<typeof ApiKey>;
512
+
513
+ export const WEBHOOK_EVENTS = [
514
+ 'post.published', 'post.updated', 'post.unpublished', 'post.deleted',
515
+ 'page.published', 'page.updated', 'page.unpublished', 'page.deleted',
516
+ 'design.published', 'media.created', 'media.updated', 'media.deleted', 'site.restored',
517
+ 'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
518
+ 'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
519
+ ] as const;
520
+ export type WebhookEventType = (typeof WEBHOOK_EVENTS)[number];
521
+
522
+ export const Webhook = z.object({
523
+ id: z.string(),
524
+ url: z.string(),
525
+ // HMAC-SHA256 signing secret (X-Acms-Signature)
526
+ secret: z.string(),
527
+ events: z.array(z.string()).default([]),
528
+ active: z.boolean().default(true),
529
+ lastStatus: z.number().nullable().default(null),
530
+ lastAt: z.number().nullable().default(null),
531
+ createdAt: z.number(),
532
+ });
533
+ export type Webhook = z.infer<typeof Webhook>;
534
+
535
+ export const WebhookDelivery = z.object({
536
+ id: z.string(),
537
+ webhookId: z.string(),
538
+ event: z.string(),
539
+ payload: z.string(),
540
+ status: z.enum(['pending', 'delivered', 'failed']),
541
+ attempts: z.number().default(0),
542
+ nextAt: z.number().nullable().default(null),
543
+ responseCode: z.number().nullable().default(null),
544
+ createdAt: z.number(),
545
+ deliveredAt: z.number().nullable().default(null),
546
+ });
547
+ export type WebhookDelivery = z.infer<typeof WebhookDelivery>;
548
+
239
549
  // normalize a redirect path: leading slash, no trailing slash, no origin
240
550
  export function normalizeRedirectPath(input: string): string {
241
551
  let p = input.trim();
@@ -249,6 +559,7 @@ export function normalizeRedirectPath(input: string): string {
249
559
  }
250
560
 
251
561
  export const Settings = z.object({
562
+ live: LiveConfig.default({}),
252
563
  siteTitle: z.string().default('My Site'),
253
564
  tagline: z.string().default(''),
254
565
  // site-wide footer, markdown allowed; empty falls back to "title — tagline"
@@ -261,6 +572,8 @@ export const Settings = z.object({
261
572
  // overrides from the Design screen layered on top of the theme's defaults
262
573
  activeTheme: z.string().default(''),
263
574
  designOverrides: z.record(z.any()).default({}),
575
+ sectionPages: SectionPages.default({}),
576
+ designRevision: z.number().int().nonnegative().default(0),
264
577
  siteLogo: z.string().default(''),
265
578
  // SEO foundation: canonical origin for absolute URLs (sitemap, feeds, OG
266
579
  // tags — empty falls back to the request origin), favicon, and the default
@@ -322,6 +635,8 @@ export const Settings = z.object({
322
635
  // external drafts API: bearer key for POST /api/drafts (empty = endpoint
323
636
  // off) and the browser origins allowed to call it ('*' entry allows all,
324
637
  // 'http://localhost:*' matches any port). Managed in /admin/settings.
638
+ ai: AiConfig,
639
+ agent: AgentConfig,
325
640
  draftsApiKey: z.string().default(''),
326
641
  draftsAllowedOrigins: z.array(z.string()).default([]),
327
642
  // staged Design-screen changes (see lib/drafts.ts); null = none pending
@@ -7,6 +7,7 @@ export interface SearchStrings {
7
7
  placeholder: string;
8
8
  posts: string;
9
9
  pages: string;
10
+ events: string;
10
11
  tags: string;
11
12
  authors: string;
12
13
  empty: string;
@@ -21,6 +22,7 @@ const STRINGS: Record<string, SearchStrings> = {
21
22
  placeholder: 'Search posts, pages, tags and authors',
22
23
  posts: 'Posts',
23
24
  pages: 'Pages',
25
+ events: 'Events',
24
26
  tags: 'Tags',
25
27
  authors: 'Authors',
26
28
  empty: 'Nothing found for',
@@ -33,6 +35,7 @@ const STRINGS: Record<string, SearchStrings> = {
33
35
  placeholder: 'Busca artículos, páginas, etiquetas y autores',
34
36
  posts: 'Artículos',
35
37
  pages: 'Páginas',
38
+ events: 'Eventos',
36
39
  tags: 'Etiquetas',
37
40
  authors: 'Autores',
38
41
  empty: 'No hay resultados para',
@@ -0,0 +1,32 @@
1
+ // Brand glyphs for social links, matched by URL host. Used by the site footer
2
+ // and the /links profile row. Unknown hosts return null and the caller falls
3
+ // back to the item's text label. Paths are 24×24, filled with currentColor.
4
+
5
+ const ICONS: [RegExp, string][] = [
6
+ [/(^|\.)(x|twitter)\.com$/, 'M17.8 3h3.1l-6.8 7.7L22 21h-6.2l-4.9-6.4L5.3 21H2.2l7.2-8.3L2 3h6.4l4.4 5.8L17.8 3Zm-1.1 16.2h1.7L7.4 4.7H5.6l11.1 14.5Z'],
7
+ [/(^|\.)github\.com$/, 'M12 2.5a9.5 9.5 0 0 0-3 18.5c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.2-1.2-3.2-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.1-.2-4.4-1.1-4.4-4.7 0-1 .4-1.9 1-2.6-.1-.2-.4-1.2.1-2.5 0 0 .8-.3 2.6 1a9 9 0 0 1 4.8 0c1.8-1.3 2.6-1 2.6-1 .5 1.3.2 2.3.1 2.5.6.7 1 1.6 1 2.6 0 3.7-2.3 4.5-4.4 4.7.3.3.6.9.6 1.8v2.6c0 .3.2.6.7.5A9.5 9.5 0 0 0 12 2.5Z'],
8
+ [/(^|\.)linkedin\.com$/, 'M5 3.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM3.3 9h3.4v11.5H3.3V9Zm5.6 0h3.3v1.6c.5-.9 1.6-1.9 3.4-1.9 3.6 0 4.2 2.3 4.2 5.4v6.4h-3.4v-5.7c0-1.4 0-3.1-1.9-3.1s-2.2 1.5-2.2 3v5.8H8.9V9Z'],
9
+ [/(^|\.)instagram\.com$/, 'M12 7.4a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2Zm0 7.6a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm5.9-7.8a1.1 1.1 0 1 1-2.2 0 1.1 1.1 0 0 1 2.2 0ZM12 4.3c2.5 0 2.8 0 3.8.1 2.6.1 3.7 1.3 3.8 3.8.1 1 .1 1.3.1 3.8s0 2.8-.1 3.8c-.1 2.5-1.3 3.7-3.8 3.8-1 .1-1.3.1-3.8.1s-2.8 0-3.8-.1c-2.6-.1-3.7-1.3-3.8-3.8-.1-1-.1-1.3-.1-3.8s0-2.8.1-3.8c.1-2.5 1.3-3.7 3.8-3.8 1-.1 1.3-.1 3.8-.1Zm0-1.7c-2.6 0-2.9 0-3.9.1-3.5.2-5.4 2.1-5.6 5.6-.1 1-.1 1.3-.1 3.9s0 2.9.1 3.9c.2 3.5 2.1 5.4 5.6 5.6 1 .1 1.3.1 3.9.1s2.9 0 3.9-.1c3.5-.2 5.4-2.1 5.6-5.6.1-1 .1-1.3.1-3.9s0-2.9-.1-3.9c-.2-3.5-2.1-5.4-5.6-5.6-1-.1-1.3-.1-3.9-.1Z'],
10
+ [/(^|\.)(youtube\.com|youtu\.be)$/, 'M21.6 7.2a2.5 2.5 0 0 0-1.8-1.8C18.2 5 12 5 12 5s-6.2 0-7.8.4A2.5 2.5 0 0 0 2.4 7.2C2 8.8 2 12 2 12s0 3.2.4 4.8a2.5 2.5 0 0 0 1.8 1.8c1.6.4 7.8.4 7.8.4s6.2 0 7.8-.4a2.5 2.5 0 0 0 1.8-1.8c.4-1.6.4-4.8.4-4.8s0-3.2-.4-4.8ZM10 15V9l5.2 3L10 15Z'],
11
+ [/(^|\.)(facebook\.com|fb\.com)$/, 'M13.5 21v-7.6h2.6l.4-3h-3V8.5c0-.9.2-1.5 1.5-1.5h1.6V4.3c-.3 0-1.2-.1-2.3-.1-2.3 0-3.9 1.4-3.9 4v2.2H7.8v3h2.6V21h3.1Z'],
12
+ [/(^|\.)tiktok\.com$/, 'M16.6 3h-3.1v12.3a2.6 2.6 0 1 1-2.6-2.6c.3 0 .5 0 .8.1V9.6a5.8 5.8 0 1 0 4.9 5.7V9.1a7.3 7.3 0 0 0 4.3 1.4V7.4a4.3 4.3 0 0 1-4.3-4.4Z'],
13
+ ];
14
+
15
+ const RSS = 'M5 3a16 16 0 0 1 16 16h-3A13 13 0 0 0 5 6V3Zm0 6a10 10 0 0 1 10 10h-3a7 7 0 0 0-7-7V9Zm1.5 6.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z';
16
+ const MAIL = 'M3 5h18a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm1.5 2v.3l7.5 5.2 7.5-5.2V7h-15Zm15 2.7-7.5 5.2-7.5-5.2V17h15V9.7Z';
17
+
18
+ // the SVG path for a social URL, or null when the host isn't recognised
19
+ export function socialIconPath(url: string): string | null {
20
+ if (url.startsWith('mailto:')) return MAIL;
21
+ if (/\/rss\.xml$|\/feed\/?$/.test(url)) return RSS;
22
+ let host = '';
23
+ try {
24
+ host = new URL(url).hostname.replace(/^www\./, '');
25
+ } catch {
26
+ return null;
27
+ }
28
+ return ICONS.find(([re]) => re.test(host))?.[1] ?? null;
29
+ }
30
+
31
+ export const socialIconSvg = (path: string) =>
32
+ `<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="${path}"/></svg>`;
@@ -0,0 +1,11 @@
1
+ // Tag marker colors. Labels render as a small rounded-square marker + text
2
+ // (never a filled pill); each tag gets a stable hue hashed from its name, so
3
+ // the same tag is the same color on every page without any stored setting.
4
+ // Mid-tone hues chosen to read on both light and dark canvases.
5
+ const PALETTE = ['#3a86d6', '#2f9e6e', '#d9822b', '#8a6cf0', '#d45a8a', '#0c8599', '#c2872a', '#6b7fd7'];
6
+
7
+ export function tagColor(name: string): string {
8
+ let h = 0;
9
+ for (const c of name.toLowerCase()) h = (h * 31 + c.charCodeAt(0)) >>> 0;
10
+ return PALETTE[h % PALETTE.length];
11
+ }