caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Per-browser state the sidebar shows at first paint, in a cookie the server reads
3
+ * (Decision 20): which Pages groups are open, and the Recent strip. Kept in a cookie
4
+ * rather than the browser's storage because the chrome is server-rendered: state the
5
+ * server can't see would paint one way and flip after hydration (M2 Decision 9).
6
+ *
7
+ * The open rule (Decision 8): a group starts open if it holds the open document;
8
+ * otherwise it's as the person last left it, and closed if they never touched it.
9
+ * Opening a document opens its ancestors in the state (withOpenAncestors), so it
10
+ * stays as they leave it afterwards.
11
+ *
12
+ * M6c (G5, Decision 18): each flat collection's List / Grid choice is kept here too, so
13
+ * the server paints the view the editor left (`views`; absent: the config's default).
14
+ */
15
+ import type { DocRef, UiState, ViewMode } from './types.ts';
16
+ import { docId } from './types.ts';
17
+
18
+ export const UI_COOKIE = 'caracms-ui';
19
+ const RECENT_MAX = 8;
20
+
21
+ export const emptyUiState = (): UiState => ({ open: {}, recent: [] });
22
+
23
+ /** A group's key: `<collection>:<folder path>` (`pages:services`, `pages:about`). */
24
+ /** The state with the sidebar hidden or shown (shown leaves no key). */
25
+ export function withSide(state: UiState, hidden: boolean): UiState {
26
+ if ((state.side === 'hidden') === hidden) return state;
27
+ if (hidden) return { ...state, side: 'hidden' };
28
+ const { side: _, ...rest } = state;
29
+ return rest;
30
+ }
31
+
32
+ export const groupKey = (collection: string, path: string) => `${collection}:${path}`;
33
+
34
+ /** The cookie's value (already URI-decoded or not; both are accepted). Never throws. */
35
+ export function parseUiState(raw: string | null | undefined): UiState {
36
+ if (!raw) return emptyUiState();
37
+ let value: unknown;
38
+ try {
39
+ value = JSON.parse(raw.startsWith('%') ? decodeURIComponent(raw) : raw);
40
+ } catch {
41
+ try {
42
+ value = JSON.parse(decodeURIComponent(raw));
43
+ } catch {
44
+ return emptyUiState();
45
+ }
46
+ }
47
+ const v = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
48
+ const open: Record<string, boolean> = {};
49
+ if (v.open && typeof v.open === 'object') {
50
+ for (const [key, on] of Object.entries(v.open as Record<string, unknown>)) {
51
+ if (typeof on === 'boolean' && key.length < 200) open[key] = on;
52
+ }
53
+ }
54
+ const recent = Array.isArray(v.recent) ? v.recent.filter((r): r is string => typeof r === 'string' && r.length < 300).slice(0, RECENT_MAX) : [];
55
+ const state: UiState = v.side === 'hidden' ? { open, recent, side: 'hidden' } : { open, recent };
56
+ // M6c (G5, Decision 18): each collection's List / Grid choice.
57
+ if (v.views && typeof v.views === 'object') {
58
+ const views: Record<string, ViewMode> = {};
59
+ for (const [name, mode] of Object.entries(v.views as Record<string, unknown>)) {
60
+ if ((mode === 'list' || mode === 'grid') && name.length < 100) views[name] = mode;
61
+ }
62
+ if (Object.keys(views).length) state.views = views;
63
+ }
64
+ return state;
65
+ }
66
+
67
+ /** M6c (G5, Decision 18): the view a collection shows: the editor's own choice, else the config's default. */
68
+ export const viewModeOf = (state: UiState, collection: string, fallback: ViewMode): ViewMode => state.views?.[collection] ?? fallback;
69
+
70
+ /** The state with a collection's List / Grid choice made. */
71
+ export function withViewMode(state: UiState, collection: string, mode: ViewMode): UiState {
72
+ return state.views?.[collection] === mode ? state : { ...state, views: { ...state.views, [collection]: mode } };
73
+ }
74
+
75
+ export const serializeUiState = (state: UiState) => encodeURIComponent(JSON.stringify(state));
76
+
77
+ /** Open every folder above a document (its groups, and pages with children above it). */
78
+ export function withOpenAncestors(state: UiState, ref: DocRef): UiState {
79
+ const segments = ref.path.split('/');
80
+ if (segments.length < 2) return state;
81
+ const open = { ...state.open };
82
+ let changed = false;
83
+ for (let i = 1; i < segments.length; i++) {
84
+ const key = groupKey(ref.collection, segments.slice(0, i).join('/'));
85
+ if (open[key] !== true) {
86
+ open[key] = true;
87
+ changed = true;
88
+ }
89
+ }
90
+ return changed ? { ...state, open } : state;
91
+ }
92
+
93
+ export function withRecent(state: UiState, ref: DocRef): UiState {
94
+ const id = docId(ref);
95
+ if (state.recent[0] === id) return state;
96
+ return { ...state, recent: [id, ...state.recent.filter((r) => r !== id)].slice(0, RECENT_MAX) };
97
+ }
98
+
99
+ export function withToggled(state: UiState, key: string, open: boolean): UiState {
100
+ return state.open[key] === open ? state : { ...state, open: { ...state.open, [key]: open } };
101
+ }
102
+
103
+ /** The cookie as the browser sets it (a year, the whole site so /admin/* all send it). */
104
+ export const uiCookieString = (state: UiState) => `${UI_COOKIE}=${serializeUiState(state)}; Path=/; Max-Age=31536000; SameSite=Lax`;
105
+
106
+ /**
107
+ * Whether this browser holds unsent edits for a site (keeper.ts), so the server can
108
+ * render the bar as Keeping at first paint after a reload rather than a Kept that
109
+ * isn't true yet. One cookie per site (`storageKey`), only while something is held.
110
+ */
111
+ export const unsentCookieName = (storageKey: string) => `caracms-unsent-${storageKey}`;
112
+ export const unsentCookieString = (storageKey: string, held: boolean) =>
113
+ held ? `${unsentCookieName(storageKey)}=1; Path=/; Max-Age=31536000; SameSite=Lax` : `${unsentCookieName(storageKey)}=; Path=/; Max-Age=0; SameSite=Lax`;
package/src/email.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The package's one email sender (M6 Decision 8): Resend's REST API through plain
3
+ * `fetch`, from `FORMS_FROM` with `RESEND_API_KEY`, both trimmed (a trailing newline in
4
+ * a sender address once broke an agency's form email for three days). The sign-in
5
+ * link (live/auth/email.ts) and the form's notification (forms/email.ts) both send
6
+ * through it.
7
+ *
8
+ * It only sends. **Whether to send is the caller's**: both send only on a production
9
+ * server (`NODE_ENV=production`) and log everywhere else, so no test from a laptop
10
+ * reaches anyone's inbox. It never throws: an unset key or sender, Resend's refusal
11
+ * or a timeout all come back as a result with a sentence.
12
+ */
13
+ export const RESEND_URL = 'https://api.resend.com/emails';
14
+ export const SEND_TIMEOUT_MS = 10_000;
15
+
16
+ export type SendResult = { status: 'sent' } | { status: 'skipped' | 'failed'; error: string };
17
+
18
+ export interface Email {
19
+ to: string[];
20
+ subject: string;
21
+ text: string;
22
+ html?: string;
23
+ replyTo?: string;
24
+ }
25
+
26
+ /** Whether this process sends email at all: only a production server does. */
27
+ export const sendsEmail = (env: Record<string, string | undefined>): boolean => env['NODE_ENV'] === 'production';
28
+
29
+ export async function sendEmail(
30
+ email: Email,
31
+ options: { env?: Record<string, string | undefined>; fetch?: typeof fetch; timeoutMs?: number } = {},
32
+ ): Promise<SendResult> {
33
+ const env = options.env ?? process.env;
34
+ const timeoutMs = options.timeoutMs ?? SEND_TIMEOUT_MS;
35
+ const apiKey = (env['RESEND_API_KEY'] ?? '').trim();
36
+ const from = (env['FORMS_FROM'] ?? '').trim();
37
+ const unset = [!apiKey && 'RESEND_API_KEY', !from && 'FORMS_FROM'].filter(Boolean);
38
+ if (unset.length) return { status: 'skipped', error: `${unset.join(' and ')} not set, so no email was sent.` };
39
+
40
+ const body: Record<string, unknown> = { from, to: email.to, subject: email.subject, text: email.text };
41
+ if (email.html) body.html = email.html;
42
+ if (email.replyTo) body.reply_to = email.replyTo;
43
+ try {
44
+ const res = await (options.fetch ?? fetch)(RESEND_URL, {
45
+ method: 'POST',
46
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
47
+ body: JSON.stringify(body),
48
+ signal: AbortSignal.timeout(timeoutMs),
49
+ });
50
+ if (res.ok) {
51
+ await res.body?.cancel().catch(() => {});
52
+ return { status: 'sent' };
53
+ }
54
+ let detail = '';
55
+ try {
56
+ const json = (await res.json()) as { message?: string; name?: string };
57
+ detail = json?.message || json?.name || '';
58
+ } catch {
59
+ /* not JSON */
60
+ }
61
+ return { status: 'failed', error: `Resend answered ${res.status}${detail ? `: ${detail}` : ''}` };
62
+ } catch (error) {
63
+ const name = (error as { name?: string } | null)?.name;
64
+ if (name === 'TimeoutError' || name === 'AbortError') return { status: 'failed', error: `Resend didn't answer within ${Math.round(timeoutMs / 1000)} s.` };
65
+ return { status: 'failed', error: `Could not reach Resend: ${error instanceof Error ? error.message : String(error)}` };
66
+ }
67
+ }