@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
@@ -1,4 +1,8 @@
1
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance } from '../schema';
1
+ import type { BookingRequest } from '../backup/content';
2
+ import type { EmailStore } from '../email/store';
3
+ export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
4
+
5
+ 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
6
 
3
7
  export interface Stats {
4
8
  publishedPages: number;
@@ -16,13 +20,32 @@ export interface SearchHit<T> {
16
20
  export interface SearchResults {
17
21
  posts: SearchHit<Post>[];
18
22
  pages: SearchHit<Page>[];
23
+ // published events; callers still apply calendar visibility (a private
24
+ // calendar's event must not surface publicly)
25
+ events: SearchHit<CalEvent>[];
19
26
  tags: { tag: Tag; count: number }[];
20
27
  authors: User[];
21
28
  }
22
29
 
30
+ export type EventUpdate = Partial<
31
+ Pick<
32
+ CalEvent,
33
+ | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
34
+ | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
35
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
36
+ | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp'
37
+ | 'publishedAt' | 'scheduledAt'
38
+ >
39
+ >;
40
+
23
41
  // The single contract both backends implement. The app only ever imports
24
42
  // this — never a concrete adapter.
25
43
  export interface ContentRepository {
44
+ readonly email: EmailStore;
45
+ // Bounded public catalog; fetch limit + 1 IDs without loading every body.
46
+ listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
47
+ exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
48
+ getBookingRequest(keyHash: string): Promise<{ requestHash: string; bookingId: string } | null>;
26
49
  // pages
27
50
  listPages(): Promise<Page[]>;
28
51
  getPage(id: string): Promise<Page | null>;
@@ -71,9 +94,86 @@ export interface ContentRepository {
71
94
  applyPostDraft(id: string): Promise<void>;
72
95
  discardPostDraft(id: string): Promise<void>;
73
96
  deletePost(id: string): Promise<void>;
74
- // flips scheduled posts whose time has passed to published; returns count
97
+ // flips scheduled posts AND events whose time has passed to published;
98
+ // returns the combined count
75
99
  publishDuePosts(now: number): Promise<number>;
76
100
 
101
+ // calendars — native calendars group events; feed calendars mirror an ICS
102
+ // URL live (their events are never stored). Deleting a calendar removes it
103
+ // from every event's calendarIds; the events themselves stay.
104
+ listCalendars(): Promise<Calendar[]>;
105
+ getCalendar(id: string): Promise<Calendar | null>;
106
+ getCalendarBySlug(slug: string): Promise<Calendar | null>;
107
+ createCalendar(data: Partial<Pick<Calendar, 'color' | 'description' | 'kind' | 'feedUrl' | 'visibility'>> & { name: string; slug: string }): Promise<Calendar>;
108
+ updateCalendar(id: string, data: Partial<Pick<Calendar, 'name' | 'slug' | 'color' | 'description' | 'feedUrl' | 'visibility' | 'feedToken'>>): Promise<void>;
109
+ reorderCalendars(ids: string[]): Promise<void>;
110
+ deleteCalendar(id: string): Promise<void>;
111
+
112
+ // events — same draft/publish contract as posts (live events stage edits
113
+ // until Update). startAt/endAt are derived from the wall-clock fields and
114
+ // the event (or site) timezone on every write.
115
+ listEvents(): Promise<CalEvent[]>;
116
+ listPublishedEvents(): Promise<CalEvent[]>;
117
+ getEvent(id: string): Promise<CalEvent | null>;
118
+ getEventBySlug(slug: string): Promise<CalEvent | null>;
119
+ createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
120
+ updateEvent(id: string, data: EventUpdate): Promise<void>;
121
+ saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
122
+ applyEventDraft(id: string): Promise<void>;
123
+ discardEventDraft(id: string): Promise<void>;
124
+ deleteEvent(id: string): Promise<void>;
125
+
126
+ // resources (rooms, people, vans…) and their booking links. Deleting a
127
+ // resource deletes its links; its bookings stay as history.
128
+ listResources(): Promise<Resource[]>;
129
+ getResource(id: string): Promise<Resource | null>;
130
+ getResourceBySlug(slug: string): Promise<Resource | null>;
131
+ createResource(data: Partial<Omit<Resource, 'id' | 'createdAt' | 'updatedAt' | 'order' | 'feedToken'>> & { name: string; slug: string }): Promise<Resource>;
132
+ updateResource(id: string, data: Partial<Omit<Resource, 'id' | 'createdAt' | 'updatedAt' | 'order'>>): Promise<void>;
133
+ reorderResources(ids: string[]): Promise<void>;
134
+ deleteResource(id: string): Promise<void>;
135
+ listBookingLinks(resourceId?: string): Promise<BookingLink[]>;
136
+ getBookingLink(id: string): Promise<BookingLink | null>;
137
+ getBookingLinkBySlug(resourceId: string, slug: string): Promise<BookingLink | null>;
138
+ createBookingLink(data: Partial<Omit<BookingLink, 'id' | 'createdAt' | 'updatedAt' | 'order'>> & { resourceId: string; name: string; slug: string }): Promise<BookingLink>;
139
+ updateBookingLink(id: string, data: Partial<Omit<BookingLink, 'id' | 'resourceId' | 'createdAt' | 'updatedAt'>>): Promise<void>;
140
+ deleteBookingLink(id: string): Promise<void>;
141
+
142
+ // bookings (+ staff blocks and event RSVPs). createBooking is the only
143
+ // write that can fail on a race: it inserts atomically only if the held
144
+ // range is still free (resource bookings) or seats remain (RSVPs), and
145
+ // returns null when it lost.
146
+ listBookings(filter?: { resourceId?: string; eventId?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
147
+ getBooking(id: string): Promise<Booking | null>;
148
+ getBookingByToken(token: string): Promise<Booking | null>;
149
+ createBooking(
150
+ data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'>,
151
+ guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
152
+ ): Promise<Booking | null>;
153
+ // moves a booking only if the new held range is free (reschedule)
154
+ moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
155
+ updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string }): Promise<void>;
156
+ deleteBooking(id: string): Promise<void>;
157
+ // confirmed bookings starting in [from, to) that haven't had a reminder
158
+ listDueReminders(from: number, to: number): Promise<Booking[]>;
159
+
160
+ // scoped API keys — only the SHA-256 hash is stored; findApiKeyByHash
161
+ // also stamps lastUsedAt
162
+ listApiKeys(): Promise<ApiKey[]>;
163
+ createApiKey(data: { name: string; prefix: string; keyHash: string; scopes: ApiScope[] }): Promise<ApiKey>;
164
+ findApiKeyByHash(keyHash: string): Promise<ApiKey | null>;
165
+ deleteApiKey(id: string): Promise<void>;
166
+
167
+ // webhooks + their delivery queue (lib/webhooks.ts drives both)
168
+ listWebhooks(): Promise<Webhook[]>;
169
+ createWebhook(data: { url: string; secret: string; events: string[] }): Promise<Webhook>;
170
+ updateWebhook(id: string, data: Partial<Pick<Webhook, 'url' | 'events' | 'active' | 'secret'>>): Promise<void>;
171
+ deleteWebhook(id: string): Promise<void>;
172
+ createDeliveries(rows: Omit<WebhookDelivery, 'attempts' | 'responseCode' | 'deliveredAt'>[]): Promise<void>;
173
+ listDueDeliveries(now: number, limit: number): Promise<WebhookDelivery[]>;
174
+ listDeliveries(webhookId: string, limit: number): Promise<WebhookDelivery[]>;
175
+ recordDelivery(id: string, data: Pick<WebhookDelivery, 'status' | 'attempts' | 'nextAt' | 'responseCode' | 'deliveredAt'>): Promise<void>;
176
+
77
177
  // media
78
178
  listMedia(): Promise<Media[]>;
79
179
  getMedia(id: string): Promise<Media | null>;
@@ -119,7 +219,7 @@ export interface ContentRepository {
119
219
  createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
120
220
  updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
121
221
  deleteUser(id: string): Promise<void>;
122
- getUserAuth(email: string): Promise<{ user: User; passHash: string } | null>;
222
+ getUserAuth(email: string): Promise<{ user: User; passHash: string; sessionVersion: number } | null>;
123
223
 
124
224
  // navigation — managed header/footer/social items; empty header falls back
125
225
  // to the legacy auto-list of published pages in Site.astro
@@ -148,6 +248,13 @@ export interface ContentRepository {
148
248
  navItems: NavItem[];
149
249
  users: User[];
150
250
  redirects: Redirect[];
251
+ // added with the calendar module; older backups restore with none
252
+ calendars?: Calendar[];
253
+ events?: CalEvent[];
254
+ resources?: Resource[];
255
+ bookingLinks?: BookingLink[];
256
+ bookings?: Booking[];
257
+ bookingRequests?: BookingRequest[];
151
258
  }): Promise<void>;
152
259
 
153
260
  // redirects — exact-path forwards, consulted when a request would 404.
@@ -161,8 +268,18 @@ export interface ContentRepository {
161
268
  updateSettings(data: Partial<Settings>): Promise<void>;
162
269
  // Design-screen staging: save stages into settings.draftDesign, apply
163
270
  // 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>;
271
+ saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
272
+ navItems?: { id: string; label: string; url: string; area: NavItem['area'] }[];
273
+ shareEpoch?: string;
274
+ }): Promise<void>;
275
+ // replace the whole staged draft (null clears it) — per-change undo, restore
276
+ setDesignDraft(draft: Record<string, unknown> | null, expectedRevision?: number, actor?: string): Promise<void>;
277
+ listDesignAudit(): Promise<Record<string, unknown>[]>;
278
+ // publish: snapshot the outgoing live design into design_versions (last 20
279
+ // kept), apply the draft's settings + staged navigation, clear the draft
280
+ applyDesignDraft(opts?: { author?: string; summary?: string; expectedRevision?: number }): Promise<void>;
166
281
  discardDesignDraft(): Promise<void>;
282
+ listDesignVersions(limit?: number): Promise<DesignVersion[]>;
283
+ getDesignVersion(id: string): Promise<DesignVersion | null>;
167
284
  getStats(): Promise<Stats>;
168
285
  }
@@ -0,0 +1,25 @@
1
+ import type { ContentRepository } from './repo/types';
2
+ import { processDueDeliveries } from './webhooks';
3
+ import { dispatchEmail } from './email/service';
4
+ import { sendDueReminders } from './booking/service';
5
+
6
+ // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
7
+ // this heartbeat: monitoring must prove the independent scheduler ran.
8
+ export async function runScheduledTasks(repo: ContentRepository, locals?: unknown) {
9
+ const startedAt = Date.now();
10
+ const settings = await repo.getSettings();
11
+ const jobs = {
12
+ publish: () => repo.publishDuePosts(startedAt),
13
+ webhooks: () => processDueDeliveries(repo),
14
+ reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
15
+ email: () => dispatchEmail(repo, locals, 10),
16
+ };
17
+ const results: Record<string, { ok: boolean; count?: number }> = {};
18
+ for (const [name, run] of Object.entries(jobs)) {
19
+ try { results[name] = { ok: true, count: await run() }; }
20
+ catch { results[name] = { ok: false }; }
21
+ }
22
+ const report = { startedAt, finishedAt: Date.now(), ok: Object.values(results).every(r => r.ok), jobs: results };
23
+ await repo.email.recordScheduler(report);
24
+ return report;
25
+ }
package/src/lib/schema.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { SectionPages } from '../theme-sdk/contract.mjs';
2
+ import { AiConfig, AgentConfig } from './ai-schema';
1
3
  import { z } from 'zod';
2
4
 
3
5
  // ---- Widgets -------------------------------------------------------------
@@ -236,6 +238,309 @@ export const Redirect = z.object({
236
238
  });
237
239
  export type Redirect = z.infer<typeof Redirect>;
238
240
 
241
+ // ---- Calendar ------------------------------------------------------------
242
+ // Calendars group events. `native` calendars hold events authored in the
243
+ // admin; `feed` calendars mirror an external ICS feed (Google, Apple,
244
+ // Outlook, any .ics) live, read-only. Visibility decides where a calendar's
245
+ // events surface: `public` = listed on the calendar page + the all-calendars
246
+ // feed; `unlisted` = event pages and its own feed work, but it's not listed;
247
+ // `private` = admin-only, plus a secret-token feed for staff subscriptions.
248
+ export const CalendarKind = z.enum(['native', 'feed']);
249
+ export type CalendarKind = z.infer<typeof CalendarKind>;
250
+ export const CalendarVisibility = z.enum(['public', 'unlisted', 'private']);
251
+ export type CalendarVisibility = z.infer<typeof CalendarVisibility>;
252
+
253
+ export const Calendar = z.object({
254
+ id: z.string(),
255
+ slug: z.string(),
256
+ name: z.string(),
257
+ color: z.string().default('#4f46e5'),
258
+ description: z.string().default(''),
259
+ kind: CalendarKind.default('native'),
260
+ // feed calendars only: the normalized ICS URL
261
+ feedUrl: z.string().default(''),
262
+ visibility: CalendarVisibility.default('public'),
263
+ // secret for /calendar/<slug>.ics?t=… on private calendars (rotatable)
264
+ feedToken: z.string().default(''),
265
+ order: z.number().default(0),
266
+ createdAt: z.number(),
267
+ updatedAt: z.number(),
268
+ });
269
+ export type Calendar = z.infer<typeof Calendar>;
270
+
271
+ // Times are stored as WALL-CLOCK strings in the event's timezone — that's
272
+ // what recurrence is defined against (a 10am weekly service stays at 10am
273
+ // across DST). Timed: 'YYYY-MM-DDTHH:mm'. All-day: 'YYYY-MM-DD', with `end`
274
+ // the INCLUSIVE last day (how people think about it; ICS output converts to
275
+ // the exclusive form). `timezone` '' = the site timezone. startAt/endAt are
276
+ // the derived UTC instants of the first occurrence, kept for sorting.
277
+ export const EventLocation = z.object({
278
+ name: z.string().default(''),
279
+ address: z.string().default(''),
280
+ // a map link or an online-meeting URL
281
+ url: z.string().default(''),
282
+ });
283
+ export type EventLocation = z.infer<typeof EventLocation>;
284
+
285
+ export const EventCta = z.object({
286
+ label: z.string().default(''),
287
+ href: z.string().default(''),
288
+ style: z.enum(['primary', 'outline']).default('primary'),
289
+ });
290
+ export type EventCta = z.infer<typeof EventCta>;
291
+
292
+ export const CalEvent = z.object({
293
+ id: z.string(),
294
+ slug: z.string(),
295
+ title: z.string(),
296
+ bodyJson: z.string().default(''),
297
+ bodyHtml: z.string().default(''),
298
+ excerpt: z.string().default(''),
299
+ cover: z.string().default(''),
300
+ coverAlt: z.string().default(''),
301
+ seoTitle: z.string().default(''),
302
+ seoDesc: z.string().default(''),
303
+ status: Status,
304
+ featured: z.boolean().default(false),
305
+ tags: z.array(z.string()).default([]),
306
+ template: z.string().default(''),
307
+ start: z.string(),
308
+ end: z.string().default(''),
309
+ allDay: z.boolean().default(false),
310
+ timezone: z.string().default(''),
311
+ // RFC 5545 RRULE body without the "RRULE:" prefix; '' = one-off
312
+ rrule: z.string().default(''),
313
+ // wall-clock starts (same format as `start`) of skipped occurrences
314
+ exdates: z.array(z.string()).default([]),
315
+ // a cancelled event stays visible (struck through) so people aren't left
316
+ // wondering where it went; feeds emit STATUS:CANCELLED
317
+ cancelled: z.boolean().default(false),
318
+ location: EventLocation.default({}),
319
+ cta: EventCta.default({}),
320
+ calendarIds: z.array(z.string()).default([]),
321
+ // values for acms({ calendar.eventFields }) custom fields, keyed by field key
322
+ fields: z.record(z.string()).default({}),
323
+ // UID of the feed event this was imported from ('' = authored here)
324
+ sourceUid: z.string().default(''),
325
+ // RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
326
+ // once; RSVPs are stored as bookings of kind 'rsvp'
327
+ rsvp: z.object({
328
+ enabled: z.boolean().default(false),
329
+ capacity: z.number().int().default(0),
330
+ autoConfirm: z.boolean().default(true),
331
+ maxSeats: z.number().int().default(4),
332
+ }).default({}),
333
+ startAt: z.number(),
334
+ endAt: z.number(),
335
+ draft: z.record(z.any()).nullable().default(null),
336
+ publishedAt: z.number().nullable().default(null),
337
+ scheduledAt: z.number().nullable().default(null),
338
+ createdAt: z.number(),
339
+ updatedAt: z.number(),
340
+ });
341
+ export type CalEvent = z.infer<typeof CalEvent>;
342
+
343
+ // ---- Bookings --------------------------------------------------------------
344
+ // A Resource is anything people reserve: a room, a person's time, a van. Its
345
+ // weekly hours + date overrides are the outer bound; each BookingLink narrows
346
+ // them and adds its own rules (length, buffers, notice, questions, whether it
347
+ // confirms automatically). Every link of a resource subtracts the same
348
+ // Bookings, so links can never double-book the resource. Times: weekly hours
349
+ // and overrides are wall-clock in the resource's zone; bookings store UTC.
350
+
351
+ export const TimeRange = z.object({
352
+ start: z.string().regex(/^\d{2}:\d{2}$/),
353
+ end: z.string().regex(/^\d{2}:\d{2}$/),
354
+ });
355
+ export type TimeRange = z.infer<typeof TimeRange>;
356
+
357
+ export const WEEKDAYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const;
358
+ export type Weekday = (typeof WEEKDAYS)[number];
359
+ export const WeeklyHours = z.object({
360
+ su: z.array(TimeRange).default([]),
361
+ mo: z.array(TimeRange).default([]),
362
+ tu: z.array(TimeRange).default([]),
363
+ we: z.array(TimeRange).default([]),
364
+ th: z.array(TimeRange).default([]),
365
+ fr: z.array(TimeRange).default([]),
366
+ sa: z.array(TimeRange).default([]),
367
+ });
368
+ export type WeeklyHours = z.infer<typeof WeeklyHours>;
369
+
370
+ // a date (or inclusive date span) with replacement hours; [] = closed
371
+ export const DateOverride = z.object({
372
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
373
+ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(''),
374
+ ranges: z.array(TimeRange).default([]),
375
+ label: z.string().default(''),
376
+ });
377
+ export type DateOverride = z.infer<typeof DateOverride>;
378
+
379
+ export const Resource = z.object({
380
+ id: z.string(),
381
+ slug: z.string(),
382
+ name: z.string(),
383
+ // a free label: Room, Person, Vehicle, Equipment… (acms({ calendar.resourceKinds }) suggests values)
384
+ kind: z.string().default('Room'),
385
+ description: z.string().default(''),
386
+ photo: z.string().default(''),
387
+ capacity: z.number().int().nullable().default(null),
388
+ amenities: z.array(z.string()).default([]),
389
+ timezone: z.string().default(''),
390
+ hours: WeeklyHours.default({}),
391
+ overrides: z.array(DateOverride).default([]),
392
+ // external ICS feeds whose events block time (a person's Google calendar)
393
+ busyFeeds: z.array(z.string()).default([]),
394
+ notifyEmails: z.array(z.string()).default([]),
395
+ // public = listed in /book; unlisted = reachable by link only
396
+ visibility: z.enum(['public', 'unlisted']).default('public'),
397
+ // confirmed bookings show on the public calendar as "Reserved" blocks
398
+ showOnCalendar: z.boolean().default(false),
399
+ // secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
400
+ feedToken: z.string().default(''),
401
+ order: z.number().default(0),
402
+ createdAt: z.number(),
403
+ updatedAt: z.number(),
404
+ });
405
+ export type Resource = z.infer<typeof Resource>;
406
+
407
+ export const QUESTION_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date'] as const;
408
+ export const BookingQuestion = z.object({
409
+ id: z.string(),
410
+ label: z.string(),
411
+ type: z.enum(QUESTION_TYPES).default('text'),
412
+ required: z.boolean().default(false),
413
+ options: z.array(z.string()).default([]),
414
+ help: z.string().default(''),
415
+ });
416
+ export type BookingQuestion = z.infer<typeof BookingQuestion>;
417
+
418
+ export const BookingMode = z.enum(['slots', 'range', 'day']);
419
+ export type BookingMode = z.infer<typeof BookingMode>;
420
+
421
+ export const BookingLink = z.object({
422
+ id: z.string(),
423
+ resourceId: z.string(),
424
+ slug: z.string(),
425
+ name: z.string(),
426
+ description: z.string().default(''),
427
+ // slots: fixed length; range: choose a length from `lengths`; day: whole days
428
+ mode: BookingMode.default('slots'),
429
+ duration: z.number().int().default(60), // minutes (slots)
430
+ lengths: z.array(z.number().int()).default([]), // minutes (range) — chips
431
+ minDays: z.number().int().default(1), // day mode
432
+ maxDays: z.number().int().default(1),
433
+ step: z.number().int().default(30), // minutes between start times
434
+ bufferBefore: z.number().int().default(0),
435
+ bufferAfter: z.number().int().default(0),
436
+ minNotice: z.number().int().default(120), // minutes
437
+ maxAdvance: z.number().int().default(90), // days
438
+ perDayCap: z.number().int().default(0), // 0 = unlimited
439
+ // narrows the resource's hours; null = use the resource's hours as-is
440
+ hours: WeeklyHours.nullable().default(null),
441
+ // confirmed at once (and on the calendar) vs. held as pending for approval
442
+ autoConfirm: z.boolean().default(true),
443
+ visibility: z.enum(['public', 'unlisted']).default('public'),
444
+ askPhone: z.boolean().default(false),
445
+ questions: z.array(BookingQuestion).default([]),
446
+ confirmMessage: z.string().default(''),
447
+ redirectUrl: z.string().default(''),
448
+ active: z.boolean().default(true),
449
+ order: z.number().default(0),
450
+ createdAt: z.number(),
451
+ updatedAt: z.number(),
452
+ });
453
+ export type BookingLink = z.infer<typeof BookingLink>;
454
+
455
+ export const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled']);
456
+ export type BookingStatus = z.infer<typeof BookingStatus>;
457
+
458
+ export const Booking = z.object({
459
+ id: z.string(),
460
+ // booking = someone reserved it; block = staff closed the time; rsvp = a
461
+ // seat at an event occurrence (resourceId/linkId empty, eventId set)
462
+ kind: z.enum(['booking', 'block', 'rsvp']).default('booking'),
463
+ resourceId: z.string().default(''),
464
+ linkId: z.string().default(''),
465
+ eventId: z.string().default(''),
466
+ status: BookingStatus,
467
+ start: z.number(),
468
+ end: z.number(),
469
+ // start/end widened by the link's buffers — what actually blocks the resource
470
+ holdStart: z.number(),
471
+ holdEnd: z.number(),
472
+ timezone: z.string().default(''),
473
+ name: z.string().default(''),
474
+ email: z.string().default(''),
475
+ phone: z.string().default(''),
476
+ seats: z.number().int().default(1),
477
+ answers: z.record(z.string()).default({}),
478
+ notes: z.string().default(''), // staff-only
479
+ manageToken: z.string().default(''),
480
+ source: z.enum(['web', 'admin', 'api', 'agent']).default('web'),
481
+ reminderSentAt: z.number().nullable().default(null),
482
+ notificationVersion: z.number().default(0),
483
+ createdAt: z.number(),
484
+ updatedAt: z.number(),
485
+ });
486
+ export type Booking = z.infer<typeof Booking>;
487
+
488
+ // ---- Integrations: scoped API keys + signed webhooks ----------------------
489
+ // Keys are shown once at creation; only a SHA-256 hash is stored. The agent
490
+ // key from Settings → AI & agents keeps working and counts as every scope.
491
+ export const API_SCOPES = [
492
+ 'events:read', 'events:write',
493
+ 'bookings:read', 'bookings:write',
494
+ 'resources:read', 'resources:write',
495
+ 'design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write',
496
+ ] as const;
497
+ export const ApiScope = z.enum(API_SCOPES);
498
+ export type ApiScope = z.infer<typeof ApiScope>;
499
+
500
+ export const ApiKey = z.object({
501
+ id: z.string(),
502
+ name: z.string(),
503
+ // first characters of the key, shown in lists so owners can tell keys apart
504
+ prefix: z.string(),
505
+ scopes: z.array(ApiScope).default([]),
506
+ lastUsedAt: z.number().nullable().default(null),
507
+ createdAt: z.number(),
508
+ });
509
+ export type ApiKey = z.infer<typeof ApiKey>;
510
+
511
+ export const WEBHOOK_EVENTS = [
512
+ 'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
513
+ 'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
514
+ ] as const;
515
+ export type WebhookEventType = (typeof WEBHOOK_EVENTS)[number];
516
+
517
+ export const Webhook = z.object({
518
+ id: z.string(),
519
+ url: z.string(),
520
+ // HMAC-SHA256 signing secret (X-Acms-Signature)
521
+ secret: z.string(),
522
+ events: z.array(z.string()).default([]),
523
+ active: z.boolean().default(true),
524
+ lastStatus: z.number().nullable().default(null),
525
+ lastAt: z.number().nullable().default(null),
526
+ createdAt: z.number(),
527
+ });
528
+ export type Webhook = z.infer<typeof Webhook>;
529
+
530
+ export const WebhookDelivery = z.object({
531
+ id: z.string(),
532
+ webhookId: z.string(),
533
+ event: z.string(),
534
+ payload: z.string(),
535
+ status: z.enum(['pending', 'delivered', 'failed']),
536
+ attempts: z.number().default(0),
537
+ nextAt: z.number().nullable().default(null),
538
+ responseCode: z.number().nullable().default(null),
539
+ createdAt: z.number(),
540
+ deliveredAt: z.number().nullable().default(null),
541
+ });
542
+ export type WebhookDelivery = z.infer<typeof WebhookDelivery>;
543
+
239
544
  // normalize a redirect path: leading slash, no trailing slash, no origin
240
545
  export function normalizeRedirectPath(input: string): string {
241
546
  let p = input.trim();
@@ -261,6 +566,8 @@ export const Settings = z.object({
261
566
  // overrides from the Design screen layered on top of the theme's defaults
262
567
  activeTheme: z.string().default(''),
263
568
  designOverrides: z.record(z.any()).default({}),
569
+ sectionPages: SectionPages.default({}),
570
+ designRevision: z.number().int().nonnegative().default(0),
264
571
  siteLogo: z.string().default(''),
265
572
  // SEO foundation: canonical origin for absolute URLs (sitemap, feeds, OG
266
573
  // tags — empty falls back to the request origin), favicon, and the default
@@ -322,6 +629,8 @@ export const Settings = z.object({
322
629
  // external drafts API: bearer key for POST /api/drafts (empty = endpoint
323
630
  // off) and the browser origins allowed to call it ('*' entry allows all,
324
631
  // 'http://localhost:*' matches any port). Managed in /admin/settings.
632
+ ai: AiConfig,
633
+ agent: AgentConfig,
325
634
  draftsApiKey: z.string().default(''),
326
635
  draftsAllowedOrigins: z.array(z.string()).default([]),
327
636
  // 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
+ }