@ekrist1/vulse 0.1.6-alpha.3 → 0.1.7-alpha.4

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 (264) hide show
  1. package/dist/cli/migrate.d.ts.map +1 -1
  2. package/dist/cli/migrate.js +3 -4
  3. package/dist/cli/setup.d.ts.map +1 -1
  4. package/dist/cli/setup.js +11 -10
  5. package/dist/core/blueprints/compile.d.ts +1 -1
  6. package/dist/core/blueprints/compile.d.ts.map +1 -1
  7. package/dist/core/blueprints/compile.js +3 -3
  8. package/dist/core/blueprints/mutations.js +2 -2
  9. package/dist/core/forms/rate-limit.d.ts +1 -1
  10. package/dist/core/forms/rate-limit.d.ts.map +1 -1
  11. package/dist/core/forms/rate-limit.js +3 -3
  12. package/dist/core/forms/unique.d.ts +1 -1
  13. package/dist/core/forms/unique.d.ts.map +1 -1
  14. package/dist/core/forms/unique.js +4 -4
  15. package/dist/core/globals/compile.d.ts +1 -1
  16. package/dist/core/globals/compile.d.ts.map +1 -1
  17. package/dist/core/globals/compile.js +2 -2
  18. package/dist/core/globals/definition.d.ts +1 -1
  19. package/dist/core/globals/definition.d.ts.map +1 -1
  20. package/dist/core/globals/definition.js +3 -3
  21. package/dist/core/repos/globals.js +3 -3
  22. package/dist/core/sha256.d.ts +3 -0
  23. package/dist/core/sha256.d.ts.map +1 -0
  24. package/dist/core/sha256.js +9 -0
  25. package/dist/integration/index.js +1 -1
  26. package/dist/integration/install-hook.d.ts.map +1 -1
  27. package/dist/integration/install-hook.js +6 -4
  28. package/dist/integration/wrangler-config.d.ts +12 -0
  29. package/dist/integration/wrangler-config.d.ts.map +1 -0
  30. package/dist/integration/wrangler-config.js +97 -0
  31. package/dist/integration/wrangler-patch.d.ts +1 -0
  32. package/dist/integration/wrangler-patch.d.ts.map +1 -1
  33. package/dist/integration/wrangler-patch.js +2 -1
  34. package/dist/server/routes/form-submit.js +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +11 -3
  38. package/src/admin/assets/logo-mark.svg +5 -0
  39. package/src/admin/client/active-locale.ts +17 -0
  40. package/src/admin/client/api.ts +21 -0
  41. package/src/admin/client/form-from-zod.ts +7 -0
  42. package/src/admin/client/live-preview-enabled.ts +5 -0
  43. package/src/admin/components/AdminShell.astro +45 -0
  44. package/src/admin/components/AuthSettings.vue +60 -0
  45. package/src/admin/components/BlockEditor.vue +53 -0
  46. package/src/admin/components/BlueprintEditor.vue +1783 -0
  47. package/src/admin/components/CollectionKindIcon.vue +26 -0
  48. package/src/admin/components/CollectionTree.vue +220 -0
  49. package/src/admin/components/EntryEditorWithPreview.vue +130 -0
  50. package/src/admin/components/EntryForm.vue +411 -0
  51. package/src/admin/components/EntryList.vue +121 -0
  52. package/src/admin/components/EntryStatusBadge.vue +24 -0
  53. package/src/admin/components/FormEditor.vue +233 -0
  54. package/src/admin/components/FormList.vue +54 -0
  55. package/src/admin/components/GlobalSetEditor.vue +272 -0
  56. package/src/admin/components/GlobalSetList.vue +55 -0
  57. package/src/admin/components/LivePreviewPanel.vue +171 -0
  58. package/src/admin/components/LoginForm.vue +53 -0
  59. package/src/admin/components/MediaLibrary.vue +106 -0
  60. package/src/admin/components/MediaPicker.vue +49 -0
  61. package/src/admin/components/RevisionDiff.vue +11 -0
  62. package/src/admin/components/RevisionList.vue +134 -0
  63. package/src/admin/components/SeoFields.vue +113 -0
  64. package/src/admin/components/SetEditor.vue +137 -0
  65. package/src/admin/components/SetList.vue +32 -0
  66. package/src/admin/components/SettingsForm.vue +189 -0
  67. package/src/admin/components/SideNav.vue +152 -0
  68. package/src/admin/components/SubmissionDetail.vue +45 -0
  69. package/src/admin/components/SubmissionList.vue +89 -0
  70. package/src/admin/components/ToastHost.vue +33 -0
  71. package/src/admin/components/TreeRow.vue +163 -0
  72. package/src/admin/components/UserEditor.vue +186 -0
  73. package/src/admin/components/UserList.vue +46 -0
  74. package/src/admin/components/blocks/BlockItem.vue +32 -0
  75. package/src/admin/components/blocks/BlockToolbar.vue +12 -0
  76. package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
  77. package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
  78. package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
  79. package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
  80. package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
  81. package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
  82. package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
  83. package/src/admin/components/fields/BlocksField.vue +123 -0
  84. package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
  85. package/src/admin/components/fields/BoolField.vue +10 -0
  86. package/src/admin/components/fields/DateField.vue +22 -0
  87. package/src/admin/components/fields/EntriesField.vue +153 -0
  88. package/src/admin/components/fields/EntryField.vue +138 -0
  89. package/src/admin/components/fields/EnumField.vue +81 -0
  90. package/src/admin/components/fields/FieldRenderer.vue +87 -0
  91. package/src/admin/components/fields/GridField.vue +173 -0
  92. package/src/admin/components/fields/LinkField.vue +219 -0
  93. package/src/admin/components/fields/MediaField.vue +69 -0
  94. package/src/admin/components/fields/NumberField.vue +12 -0
  95. package/src/admin/components/fields/ObjectField.vue +18 -0
  96. package/src/admin/components/fields/RefField.vue +170 -0
  97. package/src/admin/components/fields/RepeaterField.vue +27 -0
  98. package/src/admin/components/fields/ReplicatorField.vue +121 -0
  99. package/src/admin/components/fields/TextField.vue +11 -0
  100. package/src/admin/components/fields/TextareaField.vue +11 -0
  101. package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
  102. package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
  103. package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
  104. package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
  105. package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
  106. package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
  107. package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
  108. package/src/admin/components/fields/emoji-extension.ts +54 -0
  109. package/src/admin/components/fields/link-extension.ts +48 -0
  110. package/src/admin/components/fields/set-node-utils.ts +115 -0
  111. package/src/admin/components/fields/url-utils.ts +85 -0
  112. package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
  113. package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
  114. package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
  115. package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
  116. package/src/admin/components/fields/vulse-set-extension.ts +66 -0
  117. package/src/admin/components/fields/vulse-video-extension.ts +65 -0
  118. package/src/admin/composables/toast.ts +35 -0
  119. package/src/admin/composables/useEntrySearch.ts +112 -0
  120. package/src/admin/composables/useSets.ts +31 -0
  121. package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
  122. package/src/admin/pages/collections/[name]/[id].astro +90 -0
  123. package/src/admin/pages/collections/[name]/index.astro +31 -0
  124. package/src/admin/pages/collections/[name]/new.astro +38 -0
  125. package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
  126. package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
  127. package/src/admin/pages/forms/[handle].astro +9 -0
  128. package/src/admin/pages/forms/index.astro +7 -0
  129. package/src/admin/pages/forms/new.astro +7 -0
  130. package/src/admin/pages/index.astro +36 -0
  131. package/src/admin/pages/login.astro +14 -0
  132. package/src/admin/pages/media.astro +8 -0
  133. package/src/admin/pages/schema/[handle].astro +10 -0
  134. package/src/admin/pages/schema/new.astro +9 -0
  135. package/src/admin/pages/settings/auth.astro +9 -0
  136. package/src/admin/pages/settings/globals/[handle].astro +9 -0
  137. package/src/admin/pages/settings/globals/index.astro +7 -0
  138. package/src/admin/pages/settings/globals/new.astro +7 -0
  139. package/src/admin/pages/settings/index.astro +8 -0
  140. package/src/admin/pages/settings/sets/[handle].astro +9 -0
  141. package/src/admin/pages/settings/sets/index.astro +7 -0
  142. package/src/admin/pages/settings/sets/new.astro +7 -0
  143. package/src/admin/pages/users/[id].astro +10 -0
  144. package/src/admin/pages/users/index.astro +8 -0
  145. package/src/admin/styles/admin.css +166 -0
  146. package/src/core/access.ts +9 -0
  147. package/src/core/blocks/schema.ts +66 -0
  148. package/src/core/blueprints/code-to-definition.ts +156 -0
  149. package/src/core/blueprints/compile.ts +176 -0
  150. package/src/core/blueprints/define.ts +12 -0
  151. package/src/core/blueprints/definition.ts +185 -0
  152. package/src/core/blueprints/load.ts +144 -0
  153. package/src/core/blueprints/mutations.ts +236 -0
  154. package/src/core/blueprints/preview-path.ts +33 -0
  155. package/src/core/blueprints/reflect-fields.ts +305 -0
  156. package/src/core/blueprints/registry.ts +14 -0
  157. package/src/core/blueprints/seed.ts +20 -0
  158. package/src/core/blueprints/select-helpers.ts +30 -0
  159. package/src/core/blueprints/seo.ts +180 -0
  160. package/src/core/blueprints/types.ts +59 -0
  161. package/src/core/blueprints/zod-helpers.ts +86 -0
  162. package/src/core/db.ts +11 -0
  163. package/src/core/errors.ts +34 -0
  164. package/src/core/forms/compile.ts +84 -0
  165. package/src/core/forms/definition.ts +102 -0
  166. package/src/core/forms/rate-limit.ts +52 -0
  167. package/src/core/forms/unique.ts +38 -0
  168. package/src/core/globals/compile.ts +35 -0
  169. package/src/core/globals/definition.ts +27 -0
  170. package/src/core/locales.ts +45 -0
  171. package/src/core/migrations.ts +48 -0
  172. package/src/core/parse-content.ts +85 -0
  173. package/src/core/plugins/definition.ts +150 -0
  174. package/src/core/preview-content.ts +21 -0
  175. package/src/core/repos/entries.ts +504 -0
  176. package/src/core/repos/forms.ts +270 -0
  177. package/src/core/repos/globals.ts +179 -0
  178. package/src/core/repos/media.ts +106 -0
  179. package/src/core/repos/preview-sessions.ts +108 -0
  180. package/src/core/repos/revisions.ts +60 -0
  181. package/src/core/repos/settings.ts +23 -0
  182. package/src/core/schema.ts +244 -0
  183. package/src/core/sets/compile.ts +12 -0
  184. package/src/core/sets/definition.ts +10 -0
  185. package/src/core/sets/service.ts +82 -0
  186. package/src/core/sets/validate-tree.ts +57 -0
  187. package/src/core/sha256.ts +10 -0
  188. package/src/core/slug.ts +30 -0
  189. package/src/scaffold/collection-write.ts +83 -0
  190. package/src/scaffold/collection.ts +277 -0
  191. package/src/server/assets/live-preview-bridge.content.ts +2 -0
  192. package/src/server/assets/live-preview-bridge.js +535 -0
  193. package/src/server/better-auth.ts +82 -0
  194. package/src/server/cf-images.ts +34 -0
  195. package/src/server/cron.ts +37 -0
  196. package/src/server/email.ts +17 -0
  197. package/src/server/endpoints/api-auth.ts +10 -0
  198. package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
  199. package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
  200. package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
  201. package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
  202. package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
  203. package/src/server/endpoints/api-vulse-entries.ts +23 -0
  204. package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
  205. package/src/server/endpoints/api-vulse-form-public.ts +7 -0
  206. package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
  207. package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
  208. package/src/server/endpoints/api-vulse-forms.ts +12 -0
  209. package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
  210. package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
  211. package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
  212. package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
  213. package/src/server/endpoints/api-vulse-globals.ts +12 -0
  214. package/src/server/endpoints/api-vulse-media-file.ts +7 -0
  215. package/src/server/endpoints/api-vulse-media-id.ts +12 -0
  216. package/src/server/endpoints/api-vulse-media.ts +12 -0
  217. package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
  218. package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
  219. package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
  220. package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
  221. package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
  222. package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
  223. package/src/server/endpoints/api-vulse-revisions.ts +7 -0
  224. package/src/server/endpoints/api-vulse-search.ts +7 -0
  225. package/src/server/endpoints/api-vulse-sets.ts +23 -0
  226. package/src/server/endpoints/api-vulse-settings.ts +12 -0
  227. package/src/server/endpoints/api-vulse-users-id.ts +12 -0
  228. package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
  229. package/src/server/endpoints/api-vulse-users-role.ts +9 -0
  230. package/src/server/endpoints/api-vulse-users.ts +7 -0
  231. package/src/server/endpoints/with-runtime.ts +11 -0
  232. package/src/server/env.ts +23 -0
  233. package/src/server/envelope.ts +21 -0
  234. package/src/server/forms/email.ts +11 -0
  235. package/src/server/forms/process-submission.ts +95 -0
  236. package/src/server/forms/queue.ts +25 -0
  237. package/src/server/forms/templates.ts +24 -0
  238. package/src/server/forms/webhook.ts +19 -0
  239. package/src/server/handler.ts +66 -0
  240. package/src/server/image-probe.ts +35 -0
  241. package/src/server/loader.ts +54 -0
  242. package/src/server/plugins.ts +214 -0
  243. package/src/server/preview.ts +25 -0
  244. package/src/server/r2.ts +13 -0
  245. package/src/server/routes/blueprints.ts +62 -0
  246. package/src/server/routes/entries.ts +255 -0
  247. package/src/server/routes/form-submit.ts +168 -0
  248. package/src/server/routes/form-upload.ts +100 -0
  249. package/src/server/routes/forms.ts +88 -0
  250. package/src/server/routes/globals-public.ts +30 -0
  251. package/src/server/routes/globals.ts +93 -0
  252. package/src/server/routes/media.ts +145 -0
  253. package/src/server/routes/preview-sessions.ts +76 -0
  254. package/src/server/routes/preview.ts +36 -0
  255. package/src/server/routes/revisions.ts +29 -0
  256. package/src/server/routes/search.ts +31 -0
  257. package/src/server/routes/sets.ts +40 -0
  258. package/src/server/routes/settings.ts +24 -0
  259. package/src/server/routes/users.ts +127 -0
  260. package/src/server/runtime.ts +99 -0
  261. package/src/server/sdk/collections.ts +98 -0
  262. package/src/server/sdk/index.ts +25 -0
  263. package/src/server/sdk/media.ts +11 -0
  264. package/src/server/sdk/search.ts +90 -0
@@ -0,0 +1,24 @@
1
+ import type { FormDefinition } from '../../core/forms/definition.js'
2
+ import type { SubmissionRow } from '../../core/repos/forms.js'
3
+
4
+ export interface TemplateContext {
5
+ form: FormDefinition
6
+ submission: SubmissionRow
7
+ payload: Record<string, unknown>
8
+ }
9
+
10
+ export function renderTemplate(template: string, ctx: TemplateContext): string {
11
+ const tokens: Record<string, string> = {
12
+ 'form.label': ctx.form.label,
13
+ 'submission.id': ctx.submission.id,
14
+ 'submission.created_at': ctx.submission.createdAt.toISOString(),
15
+ }
16
+ for (const [key, value] of Object.entries(ctx.payload)) {
17
+ tokens[key] = value === null || value === undefined ? '' : String(value)
18
+ }
19
+
20
+ return template.replace(/\{\{([^}]+)\}\}/g, (_, key: string) => {
21
+ const trimmed = key.trim()
22
+ return tokens[trimmed] ?? ''
23
+ })
24
+ }
@@ -0,0 +1,19 @@
1
+ export async function sendFormWebhook(
2
+ url: string,
3
+ payload: unknown,
4
+ headers: Record<string, string> = {},
5
+ ): Promise<void> {
6
+ const controller = new AbortController()
7
+ const timeout = setTimeout(() => controller.abort(), 5000)
8
+ try {
9
+ const res = await fetch(url, {
10
+ method: 'POST',
11
+ headers: { 'content-type': 'application/json', ...headers },
12
+ body: JSON.stringify(payload),
13
+ signal: controller.signal,
14
+ })
15
+ if (!res.ok) throw new Error(`webhook failed: ${res.status}`)
16
+ } finally {
17
+ clearTimeout(timeout)
18
+ }
19
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from 'astro/zod'
2
+ import type { Auth } from './better-auth.js'
3
+ import type { AuthContext, Role } from '../core/blueprints/types.js'
4
+ import { AccessDeniedError, ValidationError } from '../core/errors.js'
5
+ import { fail, ok } from './envelope.js'
6
+
7
+ export interface HandlerCtx<P, B> {
8
+ request: Request
9
+ url: URL
10
+ params: P
11
+ body: B
12
+ auth: AuthContext
13
+ }
14
+
15
+ export interface HandlerOptions<P, B> {
16
+ params?: z.ZodType<P>
17
+ body?: z.ZodType<B>
18
+ requireRole?: Role[]
19
+ }
20
+
21
+ export function defineHandler<P = unknown, B = unknown, R = unknown>(
22
+ auth: Auth,
23
+ opts: HandlerOptions<P, B>,
24
+ fn: (ctx: HandlerCtx<P, B>) => Promise<R>,
25
+ ) {
26
+ return async (request: Request, rawParams: Record<string, string> = {}): Promise<Response> => {
27
+ try {
28
+ const url = new URL(request.url)
29
+
30
+ let params: P = rawParams as unknown as P
31
+ if (opts.params) {
32
+ const parsed = opts.params.safeParse(rawParams)
33
+ if (!parsed.success) throw new ValidationError('Invalid params', { issues: parsed.error.issues })
34
+ params = parsed.data
35
+ }
36
+
37
+ let body: B = undefined as unknown as B
38
+ if (opts.body && request.method !== 'GET' && request.method !== 'DELETE') {
39
+ const raw = await request.json().catch(() => undefined)
40
+ const parsed = opts.body.safeParse(raw)
41
+ if (!parsed.success) throw new ValidationError('Invalid body', { issues: parsed.error.issues })
42
+ body = parsed.data
43
+ }
44
+
45
+ const session = await auth.api.getSession({ headers: request.headers })
46
+ const authCtx: AuthContext = session ? {
47
+ user: {
48
+ id: session.user.id,
49
+ email: session.user.email,
50
+ role: (session.user as { role?: Role }).role ?? 'member',
51
+ },
52
+ } : { user: null }
53
+
54
+ if (opts.requireRole && !authCtx.user) throw new AccessDeniedError('Authentication required')
55
+ if (opts.requireRole && authCtx.user && !opts.requireRole.includes(authCtx.user.role)) {
56
+ throw new AccessDeniedError(`Requires role: ${opts.requireRole.join(' or ')}`)
57
+ }
58
+
59
+ const result = await fn({ request, url, params, body, auth: authCtx })
60
+ if (result instanceof Response) return result
61
+ return ok(result)
62
+ } catch (err) {
63
+ return fail(err)
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,35 @@
1
+ /** Returns {width,height} from image headers without decoding the full file. */
2
+ export function probeDimensions(buf: ArrayBuffer, mime: string): { width: number; height: number } | null {
3
+ const v = new DataView(buf)
4
+ if (mime === 'image/png') {
5
+ if (v.byteLength < 24) return null
6
+ return { width: v.getUint32(16), height: v.getUint32(20) }
7
+ }
8
+ if (mime === 'image/jpeg') {
9
+ let i = 2
10
+ while (i < v.byteLength) {
11
+ if (v.getUint8(i) !== 0xff) return null
12
+ const marker = v.getUint8(i + 1)
13
+ const len = v.getUint16(i + 2)
14
+ if ((marker >= 0xc0 && marker <= 0xcf) && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
15
+ return { height: v.getUint16(i + 5), width: v.getUint16(i + 7) }
16
+ }
17
+ i += 2 + len
18
+ }
19
+ return null
20
+ }
21
+ if (mime === 'image/webp') {
22
+ if (v.byteLength < 30) return null
23
+ if (String.fromCharCode(v.getUint8(12), v.getUint8(13), v.getUint8(14), v.getUint8(15)) === 'VP8L') {
24
+ const b0 = v.getUint8(21)
25
+ const b1 = v.getUint8(22)
26
+ const b2 = v.getUint8(23)
27
+ const b3 = v.getUint8(24)
28
+ const width = 1 + (((b1 & 0x3f) << 8) | b0)
29
+ const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6))
30
+ return { width, height }
31
+ }
32
+ return null
33
+ }
34
+ return null
35
+ }
@@ -0,0 +1,54 @@
1
+ import type { Loader } from 'astro/loaders'
2
+ import { createDb } from '../core/db.js'
3
+ import { readLocalesConfig } from '../core/locales.js'
4
+ import { EntriesRepo } from '../core/repos/entries.js'
5
+
6
+ export interface VulseLoaderOptions {
7
+ collection: string
8
+ locale?: string
9
+ }
10
+
11
+ declare global {
12
+ // eslint-disable-next-line no-var
13
+ var __VULSE_TEST_DB__: D1Database | undefined
14
+ }
15
+
16
+ function resolveBinding(ctx: unknown): D1Database {
17
+ const c = ctx as { _vulseTestBinding?: D1Database }
18
+ if (c._vulseTestBinding) return c._vulseTestBinding
19
+ if (globalThis.__VULSE_TEST_DB__) return globalThis.__VULSE_TEST_DB__
20
+ throw new Error('vulseLoader: no D1 binding available. See https://vulse.dev/docs/loader-binding')
21
+ }
22
+
23
+ export function vulseLoader(opts: VulseLoaderOptions): Loader {
24
+ return {
25
+ name: `vulse-loader-${opts.collection}`,
26
+ load: async (ctx) => {
27
+ const includeDrafts = (ctx as { _vulseIncludeDrafts?: boolean })._vulseIncludeDrafts ?? false
28
+ const db = createDb(resolveBinding(ctx))
29
+ const repo = new EntriesRepo(db)
30
+ const locale = opts.locale ?? (await readLocalesConfig(db)).defaultLocale
31
+ const rows = await repo.list({
32
+ collection: opts.collection,
33
+ locale,
34
+ ...(includeDrafts ? {} : { status: 'published' }),
35
+ })
36
+
37
+ ctx.store.clear()
38
+ for (const r of rows) {
39
+ await ctx.store.set({
40
+ id: r.id,
41
+ digest: `v${r.version}`,
42
+ data: {
43
+ ...((r.content as Record<string, unknown>) ?? {}),
44
+ id: r.id,
45
+ slug: r.slug,
46
+ status: r.status,
47
+ publishedAt: r.publishedAt?.toISOString() ?? null,
48
+ updatedAt: r.updatedAt.toISOString(),
49
+ },
50
+ })
51
+ }
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,214 @@
1
+ import { ValidationError } from '../core/errors.js'
2
+ import {
3
+ assertValidPluginId,
4
+ type AuthUserBeforeCreateResult,
5
+ type AuthUserCreateEvent,
6
+ type AuthUserCreateInput,
7
+ type AuthUserCreatedEvent,
8
+ type FormAfterSubmitEvent,
9
+ type FormBeforeSubmitEvent,
10
+ type FormBeforeSubmitResult,
11
+ type FormProcessEvent,
12
+ type MaybePromise,
13
+ type VulseHookName,
14
+ type VulsePlugin,
15
+ type VulsePluginContext,
16
+ } from '../core/plugins/definition.js'
17
+ import { sendFormEmail } from './forms/email.js'
18
+ import type { FormEmailEnv } from './forms/email.js'
19
+
20
+ const DEFAULT_HOOK_TIMEOUT_MS = 5_000
21
+
22
+ interface RegisteredPlugin {
23
+ plugin: VulsePlugin
24
+ order: number
25
+ }
26
+
27
+ let registeredPlugins: RegisteredPlugin[] = []
28
+
29
+ function orderedPlugins(): RegisteredPlugin[] {
30
+ return [...registeredPlugins].sort((a, b) => {
31
+ const priority = (b.plugin.priority ?? 0) - (a.plugin.priority ?? 0)
32
+ return priority || a.order - b.order
33
+ })
34
+ }
35
+
36
+ export function setVulsePlugins(plugins: VulsePlugin[] = []): void {
37
+ const seen = new Set<string>()
38
+ registeredPlugins = plugins.map((plugin, order) => {
39
+ assertValidPluginId(plugin.id)
40
+ if (seen.has(plugin.id)) throw new Error(`Vulse plugin "${plugin.id}" is registered more than once`)
41
+ seen.add(plugin.id)
42
+ return { plugin, order }
43
+ })
44
+ }
45
+
46
+ export function getVulsePlugins(): VulsePlugin[] {
47
+ return orderedPlugins().map(({ plugin }) => plugin)
48
+ }
49
+
50
+ export function __testResetVulsePlugins(): void {
51
+ registeredPlugins = []
52
+ }
53
+
54
+ function loggerFor(pluginId: string): VulsePluginContext['logger'] {
55
+ return {
56
+ debug: (message, data) => console.debug(`[vulse:${pluginId}] ${message}`, data ?? ''),
57
+ info: (message, data) => console.info(`[vulse:${pluginId}] ${message}`, data ?? ''),
58
+ warn: (message, data) => console.warn(`[vulse:${pluginId}] ${message}`, data ?? ''),
59
+ error: (message, data) => console.error(`[vulse:${pluginId}] ${message}`, data ?? ''),
60
+ }
61
+ }
62
+
63
+ function pluginContext(pluginId: string, env?: Record<string, unknown>): VulsePluginContext {
64
+ const safeEnv = env ?? {}
65
+ return {
66
+ env: safeEnv,
67
+ logger: loggerFor(pluginId),
68
+ email: {
69
+ send: async (input) => {
70
+ await sendFormEmail(safeEnv as FormEmailEnv, {
71
+ to: input.to,
72
+ subject: input.subject,
73
+ body: input.body ?? input.text ?? input.html ?? '',
74
+ })
75
+ },
76
+ },
77
+ }
78
+ }
79
+
80
+ async function withTimeout<T>(
81
+ promise: Promise<T>,
82
+ pluginId: string,
83
+ hookName: VulseHookName,
84
+ ): Promise<T> {
85
+ let timeout: ReturnType<typeof setTimeout> | undefined
86
+ try {
87
+ return await Promise.race([
88
+ promise,
89
+ new Promise<never>((_, reject) => {
90
+ timeout = setTimeout(() => {
91
+ reject(new Error(`Vulse plugin "${pluginId}" hook "${hookName}" timed out`))
92
+ }, DEFAULT_HOOK_TIMEOUT_MS)
93
+ }),
94
+ ])
95
+ } finally {
96
+ if (timeout) clearTimeout(timeout)
97
+ }
98
+ }
99
+
100
+ async function invokeHook<TEvent, TResult>(
101
+ plugin: VulsePlugin,
102
+ hookName: VulseHookName,
103
+ event: TEvent,
104
+ env: Record<string, unknown> | undefined,
105
+ ): Promise<TResult | undefined> {
106
+ const hook = plugin.hooks?.[hookName] as
107
+ | ((event: TEvent, ctx: VulsePluginContext) => MaybePromise<TResult>)
108
+ | undefined
109
+ if (!hook) return undefined
110
+ return await withTimeout(
111
+ Promise.resolve(hook(event, pluginContext(plugin.id, env))),
112
+ plugin.id,
113
+ hookName,
114
+ )
115
+ }
116
+
117
+ async function runContinueHook<TEvent>(
118
+ hookName: VulseHookName,
119
+ event: TEvent,
120
+ env?: Record<string, unknown>,
121
+ ): Promise<void> {
122
+ for (const { plugin } of orderedPlugins()) {
123
+ try {
124
+ await invokeHook<TEvent, void>(plugin, hookName, event, env)
125
+ } catch (err) {
126
+ pluginContext(plugin.id, env).logger.error(`Hook "${hookName}" failed`, err)
127
+ }
128
+ }
129
+ }
130
+
131
+ export async function runFormBeforeSubmitHooks(
132
+ event: FormBeforeSubmitEvent,
133
+ env?: Record<string, unknown>,
134
+ ): Promise<{ action: 'continue'; payload: Record<string, unknown> } | { action: 'drop'; reason?: string }> {
135
+ let payload = event.payload
136
+
137
+ for (const { plugin } of orderedPlugins()) {
138
+ const result = await invokeHook<FormBeforeSubmitEvent, FormBeforeSubmitResult>(
139
+ plugin,
140
+ 'form:beforeSubmit',
141
+ { ...event, payload },
142
+ env,
143
+ )
144
+ if (!result) continue
145
+
146
+ if (result.action === 'drop') {
147
+ return { action: 'drop', ...(result.reason ? { reason: result.reason } : {}) }
148
+ }
149
+ if (result.action === 'reject') {
150
+ throw new ValidationError(result.message ?? `Submission rejected by plugin "${plugin.id}"`, { plugin: plugin.id })
151
+ }
152
+ if (result.payload) payload = result.payload
153
+ }
154
+
155
+ return { action: 'continue', payload }
156
+ }
157
+
158
+ export async function runFormAfterSubmitHooks(
159
+ event: FormAfterSubmitEvent,
160
+ env?: Record<string, unknown>,
161
+ ): Promise<void> {
162
+ await runContinueHook('form:afterSubmit', event, env)
163
+ }
164
+
165
+ export async function runFormBeforeProcessHooks(
166
+ event: FormProcessEvent,
167
+ env?: Record<string, unknown>,
168
+ ): Promise<void> {
169
+ for (const { plugin } of orderedPlugins()) {
170
+ await invokeHook<FormProcessEvent, void>(plugin, 'form:beforeProcess', event, env)
171
+ }
172
+ }
173
+
174
+ export async function runFormAfterProcessHooks(
175
+ event: FormProcessEvent,
176
+ env?: Record<string, unknown>,
177
+ ): Promise<void> {
178
+ await runContinueHook('form:afterProcess', event, env)
179
+ }
180
+
181
+ export async function runAuthUserBeforeCreateHooks(
182
+ event: AuthUserCreateEvent,
183
+ env?: Record<string, unknown>,
184
+ ): Promise<AuthUserCreateInput | false | undefined> {
185
+ let user = event.user
186
+ let changed = false
187
+
188
+ for (const { plugin } of orderedPlugins()) {
189
+ const result = await invokeHook<AuthUserCreateEvent, AuthUserBeforeCreateResult>(
190
+ plugin,
191
+ 'auth:userBeforeCreate',
192
+ { user },
193
+ env,
194
+ )
195
+ if (result === false) return false
196
+ if (!result) continue
197
+ if (result.action === 'reject') {
198
+ throw new Error(result.message ?? `User rejected by plugin "${plugin.id}"`)
199
+ }
200
+ if (result.data) {
201
+ user = { ...user, ...result.data }
202
+ changed = true
203
+ }
204
+ }
205
+
206
+ return changed ? user : undefined
207
+ }
208
+
209
+ export async function runAuthUserAfterCreateHooks(
210
+ event: AuthUserCreatedEvent,
211
+ env?: Record<string, unknown>,
212
+ ): Promise<void> {
213
+ await runContinueHook('auth:userAfterCreate', event, env)
214
+ }
@@ -0,0 +1,25 @@
1
+ import { SignJWT, jwtVerify } from 'jose'
2
+
3
+ const ALG = 'HS256'
4
+
5
+ export async function mintPreviewToken(secret: string, userId: string, ttlSeconds = 60 * 60): Promise<string> {
6
+ const key = new TextEncoder().encode(secret)
7
+ return await new SignJWT({ sub: userId, kind: 'vulse-preview' })
8
+ .setProtectedHeader({ alg: ALG })
9
+ .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds)
10
+ .sign(key)
11
+ }
12
+
13
+ export async function verifyPreviewToken(secret: string, token: string): Promise<boolean> {
14
+ try {
15
+ const key = new TextEncoder().encode(secret)
16
+ const { payload } = await jwtVerify(token, key)
17
+ return payload.kind === 'vulse-preview'
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ export function previewSecret(env: { VULSE_PREVIEW_SECRET?: string; BETTER_AUTH_SECRET: string }): string {
24
+ return env.VULSE_PREVIEW_SECRET ?? env.BETTER_AUTH_SECRET
25
+ }
@@ -0,0 +1,13 @@
1
+ import { nanoid } from 'nanoid'
2
+
3
+ export interface UploadContext { bucket: R2Bucket }
4
+
5
+ export async function putToR2(ctx: UploadContext, body: ArrayBuffer, mime: string): Promise<{ key: string }> {
6
+ const key = `${new Date().toISOString().slice(0, 10)}/${nanoid()}`
7
+ await ctx.bucket.put(key, body, { httpMetadata: { contentType: mime } })
8
+ return { key }
9
+ }
10
+
11
+ export async function deleteFromR2(ctx: UploadContext, key: string): Promise<void> {
12
+ await ctx.bucket.delete(key)
13
+ }
@@ -0,0 +1,62 @@
1
+ import { z } from 'astro/zod'
2
+ import type { Auth } from '../better-auth.js'
3
+ import type { VulseDb } from '../../core/db.js'
4
+ import {
5
+ BlueprintDefinitionSchema,
6
+ BlueprintDefinitionWithRenamesSchema,
7
+ } from '../../core/blueprints/definition.js'
8
+ import {
9
+ createBlueprint,
10
+ deleteBlueprint,
11
+ getBlueprintDefinition,
12
+ listBlueprintDefinitions,
13
+ updateBlueprint,
14
+ } from '../../core/blueprints/mutations.js'
15
+ import { _resetRegistry } from '../../core/blueprints/load.js'
16
+ import { _resetRuntime } from '../runtime.js'
17
+ import { defineHandler } from '../handler.js'
18
+
19
+ const paramsHandle = z.object({ handle: z.string() })
20
+
21
+ export function blueprintsRoutes(db: VulseDb, auth: Auth) {
22
+ return {
23
+ list: defineHandler(auth, {}, async () => listBlueprintDefinitions(db)),
24
+
25
+ get: defineHandler(auth, { params: paramsHandle }, async ({ params }) => {
26
+ const def = await getBlueprintDefinition(db, params.handle)
27
+ if (!def) throw new (await import('../../core/errors.js')).NotFoundError('blueprint not found')
28
+ return def
29
+ }),
30
+
31
+ create: defineHandler(auth, {
32
+ body: BlueprintDefinitionSchema,
33
+ requireRole: ['admin'],
34
+ }, async ({ body }) => {
35
+ const out = await createBlueprint(db, body)
36
+ _resetRegistry()
37
+ _resetRuntime()
38
+ return out
39
+ }),
40
+
41
+ update: defineHandler(auth, {
42
+ params: paramsHandle,
43
+ body: BlueprintDefinitionWithRenamesSchema,
44
+ requireRole: ['admin'],
45
+ }, async ({ params, body }) => {
46
+ const out = await updateBlueprint(db, params.handle, body)
47
+ _resetRegistry()
48
+ _resetRuntime()
49
+ return out
50
+ }),
51
+
52
+ delete: defineHandler(auth, {
53
+ params: paramsHandle,
54
+ requireRole: ['admin'],
55
+ }, async ({ params }) => {
56
+ await deleteBlueprint(db, params.handle)
57
+ _resetRegistry()
58
+ _resetRuntime()
59
+ return null
60
+ }),
61
+ }
62
+ }