@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
package/src/core/db.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { drizzle } from 'drizzle-orm/d1'
2
+ import * as schema from './schema.js'
3
+
4
+ export type VulseDb = ReturnType<typeof createDb>
5
+
6
+ export function createDb(binding: D1Database) {
7
+ if (!binding) throw new Error('Vulse: D1 binding "DB" is missing. Add it to wrangler.toml.')
8
+ return drizzle(binding, { schema })
9
+ }
10
+
11
+ export * as schema from './schema.js'
@@ -0,0 +1,34 @@
1
+ export type ErrorCode = 'VALIDATION' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'CONFLICT' | 'INTERNAL'
2
+
3
+ export class VulseError extends Error {
4
+ readonly code: ErrorCode
5
+ readonly status: number
6
+ readonly details?: Record<string, unknown>
7
+
8
+ constructor(code: ErrorCode, status: number, message: string, details?: Record<string, unknown>) {
9
+ super(message)
10
+ this.name = 'VulseError'
11
+ this.code = code
12
+ this.status = status
13
+ if (details !== undefined) {
14
+ this.details = details
15
+ }
16
+ }
17
+
18
+ static isVulseError(e: unknown): e is VulseError {
19
+ return e instanceof VulseError
20
+ }
21
+ }
22
+
23
+ export class ValidationError extends VulseError {
24
+ constructor(message: string, details?: Record<string, unknown>) { super('VALIDATION', 422, message, details) }
25
+ }
26
+ export class NotFoundError extends VulseError {
27
+ constructor(message = 'Not found') { super('NOT_FOUND', 404, message) }
28
+ }
29
+ export class AccessDeniedError extends VulseError {
30
+ constructor(message = 'Access denied') { super('ACCESS_DENIED', 403, message) }
31
+ }
32
+ export class ConflictError extends VulseError {
33
+ constructor(message: string, details?: Record<string, unknown>) { super('CONFLICT', 409, message, details) }
34
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from 'astro/zod'
2
+ import type { FormDefinition, FormFieldDefinition } from './definition.js'
3
+
4
+ export interface CompiledForm {
5
+ schema: z.ZodObject<z.ZodRawShape>
6
+ inputFields: FormFieldDefinition[]
7
+ uniqueFields: string[]
8
+ }
9
+
10
+ const LAYOUT_KINDS = new Set(['submit', 'honeypot'])
11
+
12
+ export function compileForm(def: FormDefinition): CompiledForm {
13
+ const inputFields = def.fields.filter((f) => !LAYOUT_KINDS.has(f.ui.kind))
14
+ const uniqueFields = inputFields
15
+ .filter((f) => f.validation?.unique)
16
+ .map((f) => f.name)
17
+
18
+ const shape: Record<string, z.ZodTypeAny> = {}
19
+ for (const field of inputFields) {
20
+ shape[field.name] = compileFormField(field)
21
+ }
22
+
23
+ return {
24
+ schema: z.object(shape),
25
+ inputFields,
26
+ uniqueFields,
27
+ }
28
+ }
29
+
30
+ function compileFormField(f: FormFieldDefinition): z.ZodTypeAny {
31
+ let s: z.ZodTypeAny = z.never()
32
+ const v = f.validation
33
+
34
+ switch (f.ui.kind) {
35
+ case 'text':
36
+ case 'textarea': {
37
+ let str = z.string()
38
+ if (v?.min !== undefined) str = str.min(v.min)
39
+ if (v?.max !== undefined) str = str.max(v.max)
40
+ if (v?.pattern) str = str.regex(new RegExp(v.pattern))
41
+ if (v?.url) str = str.url()
42
+ s = str
43
+ break
44
+ }
45
+ case 'email': {
46
+ let str = z.string().email()
47
+ if (v?.min !== undefined) str = str.min(v.min)
48
+ if (v?.max !== undefined) str = str.max(v.max)
49
+ s = str
50
+ break
51
+ }
52
+ case 'number': {
53
+ let num = v?.integer ? z.coerce.number().int() : z.coerce.number()
54
+ if (v?.min !== undefined) num = num.min(v.min)
55
+ if (v?.max !== undefined) num = num.max(v.max)
56
+ s = num
57
+ break
58
+ }
59
+ case 'date':
60
+ case 'time':
61
+ case 'datetime':
62
+ s = z.string()
63
+ break
64
+ case 'select':
65
+ case 'radio':
66
+ s = z.enum(f.ui.options as [string, ...string[]])
67
+ break
68
+ case 'checkbox':
69
+ s = z.boolean()
70
+ break
71
+ case 'file':
72
+ s = z.string().min(1)
73
+ break
74
+ case 'hidden':
75
+ s = z.string()
76
+ break
77
+ }
78
+
79
+ if (f.default !== undefined) s = s.default(f.default)
80
+ if (f.optional && !v?.required) s = s.optional()
81
+ else if (v?.required) s = s.refine((val) => val !== '' && val !== undefined && val !== null, { message: 'Required' })
82
+
83
+ return s
84
+ }
@@ -0,0 +1,102 @@
1
+ import { z } from 'astro/zod'
2
+
3
+ const handleRegex = /^[a-z][a-z0-9_-]*$/
4
+ const fieldNameRegex = /^[a-z_][a-z0-9_-]*$/
5
+
6
+ export const FormFieldValidationSchema = z.object({
7
+ required: z.boolean().optional(),
8
+ min: z.number().optional(),
9
+ max: z.number().optional(),
10
+ pattern: z.string().optional(),
11
+ email: z.boolean().optional(),
12
+ url: z.boolean().optional(),
13
+ integer: z.boolean().optional(),
14
+ unique: z.boolean().optional(),
15
+ })
16
+
17
+ export type FormFieldValidation = z.infer<typeof FormFieldValidationSchema>
18
+
19
+ export const FormFieldUiSchema = z.discriminatedUnion('kind', [
20
+ z.object({ kind: z.literal('text') }),
21
+ z.object({ kind: z.literal('textarea') }),
22
+ z.object({ kind: z.literal('email') }),
23
+ z.object({ kind: z.literal('number') }),
24
+ z.object({ kind: z.literal('date') }),
25
+ z.object({ kind: z.literal('time') }),
26
+ z.object({ kind: z.literal('datetime') }),
27
+ z.object({ kind: z.enum(['select', 'radio']), options: z.array(z.string()).min(1) }),
28
+ z.object({ kind: z.literal('checkbox'), label: z.string().optional() }),
29
+ z.object({
30
+ kind: z.literal('file'),
31
+ accept: z.array(z.string()).optional(),
32
+ maxBytes: z.number().int().positive().optional(),
33
+ }),
34
+ z.object({ kind: z.literal('hidden'), value: z.string().optional() }),
35
+ z.object({ kind: z.literal('honeypot') }),
36
+ z.object({ kind: z.literal('submit'), label: z.string().optional() }),
37
+ ])
38
+
39
+ export type FormFieldUi = z.infer<typeof FormFieldUiSchema>
40
+
41
+ export const FormFieldDefinitionSchema = z.object({
42
+ name: z.string().regex(fieldNameRegex),
43
+ label: z.string().optional(),
44
+ ui: FormFieldUiSchema,
45
+ optional: z.boolean(),
46
+ default: z.unknown().optional(),
47
+ validation: FormFieldValidationSchema.optional(),
48
+ })
49
+
50
+ export type FormFieldDefinition = z.infer<typeof FormFieldDefinitionSchema>
51
+
52
+ export const FormSettingsSchema = z.object({
53
+ enabled: z.boolean().default(true),
54
+ successMessage: z.string().optional(),
55
+ redirectTo: z.string().optional(),
56
+ honeypotField: z.string().optional(),
57
+ rateLimit: z.object({
58
+ maxPerIp: z.number().int().positive(),
59
+ windowSec: z.number().int().positive(),
60
+ }).optional(),
61
+ uploadDraftTtlHours: z.number().int().positive().optional(),
62
+ notifyEmails: z.array(z.string().email()).optional(),
63
+ confirmationEmail: z.object({
64
+ enabled: z.boolean(),
65
+ toField: z.string(),
66
+ subject: z.string(),
67
+ bodyTemplate: z.string(),
68
+ }).optional(),
69
+ })
70
+
71
+ export type FormSettings = z.infer<typeof FormSettingsSchema>
72
+
73
+ export const FormActionSchema = z.discriminatedUnion('type', [
74
+ z.object({
75
+ type: z.literal('notify'),
76
+ emails: z.array(z.string().email()),
77
+ template: z.string().optional(),
78
+ }),
79
+ z.object({
80
+ type: z.literal('confirmation'),
81
+ toField: z.string(),
82
+ subject: z.string(),
83
+ bodyTemplate: z.string(),
84
+ }),
85
+ z.object({
86
+ type: z.literal('webhook'),
87
+ url: z.string().url(),
88
+ headers: z.record(z.string(), z.string()).optional(),
89
+ }),
90
+ ])
91
+
92
+ export type FormAction = z.infer<typeof FormActionSchema>
93
+
94
+ export const FormDefinitionSchema = z.object({
95
+ handle: z.string().regex(handleRegex),
96
+ label: z.string().min(1),
97
+ fields: z.array(FormFieldDefinitionSchema).default([]),
98
+ settings: FormSettingsSchema,
99
+ actions: z.array(FormActionSchema).default([]),
100
+ })
101
+
102
+ export type FormDefinition = z.infer<typeof FormDefinitionSchema>
@@ -0,0 +1,52 @@
1
+ import { sha256Hex } from '../sha256.js'
2
+ import { eq, and, lt } from 'drizzle-orm'
3
+ import type { VulseDb } from '../db.js'
4
+ import { vulseFormRateLimits } from '../schema.js'
5
+
6
+ export async function hashIp(ip: string): Promise<string> {
7
+ return sha256Hex(ip)
8
+ }
9
+
10
+ export async function checkRateLimit(
11
+ db: VulseDb,
12
+ formHandle: string,
13
+ ipHash: string,
14
+ opts: { maxPerIp: number; windowSec: number } = { maxPerIp: 10, windowSec: 3600 },
15
+ ): Promise<{ allowed: boolean; retryAfterSec?: number }> {
16
+ const windowMs = opts.windowSec * 1000
17
+ const now = Date.now()
18
+ const windowStart = new Date(Math.floor(now / windowMs) * windowMs)
19
+
20
+ const existing = await db.select().from(vulseFormRateLimits)
21
+ .where(and(
22
+ eq(vulseFormRateLimits.formHandle, formHandle),
23
+ eq(vulseFormRateLimits.ipHash, ipHash),
24
+ eq(vulseFormRateLimits.windowStart, windowStart),
25
+ ))
26
+ .get()
27
+
28
+ if (!existing) {
29
+ await db.insert(vulseFormRateLimits).values({
30
+ formHandle,
31
+ ipHash,
32
+ windowStart,
33
+ count: 1,
34
+ })
35
+ return { allowed: true }
36
+ }
37
+
38
+ if (existing.count >= opts.maxPerIp) {
39
+ const retryAfterSec = Math.ceil((windowStart.getTime() + windowMs - now) / 1000)
40
+ return { allowed: false, retryAfterSec }
41
+ }
42
+
43
+ await db.update(vulseFormRateLimits)
44
+ .set({ count: existing.count + 1 })
45
+ .where(and(
46
+ eq(vulseFormRateLimits.formHandle, formHandle),
47
+ eq(vulseFormRateLimits.ipHash, ipHash),
48
+ eq(vulseFormRateLimits.windowStart, windowStart),
49
+ ))
50
+
51
+ return { allowed: true }
52
+ }
@@ -0,0 +1,38 @@
1
+ import { sha256Hex } from '../sha256.js'
2
+ import type { VulseDb } from '../db.js'
3
+ import { vulseFormUniqueValues } from '../schema.js'
4
+ import { ConflictError } from '../errors.js'
5
+
6
+ export function normalizeUniqueValue(value: unknown): string {
7
+ return String(value).trim().toLowerCase()
8
+ }
9
+
10
+ export async function hashUniqueValue(value: unknown): Promise<string> {
11
+ return sha256Hex(normalizeUniqueValue(value))
12
+ }
13
+
14
+ export async function insertUniqueValues(
15
+ db: VulseDb,
16
+ formHandle: string,
17
+ submissionId: string,
18
+ fields: Record<string, unknown>,
19
+ uniqueFieldNames: string[],
20
+ ): Promise<void> {
21
+ const now = new Date()
22
+ for (const name of uniqueFieldNames) {
23
+ const value = fields[name]
24
+ if (value === undefined || value === null || value === '') continue
25
+ const valueHash = await hashUniqueValue(value)
26
+ try {
27
+ await db.insert(vulseFormUniqueValues).values({
28
+ formHandle,
29
+ fieldName: name,
30
+ valueHash,
31
+ submissionId,
32
+ createdAt: now,
33
+ })
34
+ } catch {
35
+ throw new ConflictError(`Duplicate value for field "${name}"`, { field: name })
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,35 @@
1
+ import type { z } from 'astro/zod'
2
+ import { compileBlueprintSchema } from '../blueprints/compile.js'
3
+ import type { CompiledSet } from '../sets/compile.js'
4
+ import type { FieldDefinition } from '../blueprints/definition.js'
5
+ import { type GlobalSetDefinition, hashGlobalSetDefinition } from './definition.js'
6
+
7
+ export interface CompiledGlobalSet {
8
+ handle: string
9
+ label: string
10
+ fields: FieldDefinition[]
11
+ schema: z.ZodObject<z.ZodRawShape>
12
+ hash: string
13
+ }
14
+
15
+ export async function compileGlobalSet(
16
+ def: GlobalSetDefinition,
17
+ sets?: Map<string, CompiledSet>,
18
+ ): Promise<CompiledGlobalSet> {
19
+ const options = sets ? { sets } : {}
20
+ return {
21
+ handle: def.handle,
22
+ label: def.label,
23
+ fields: def.fields,
24
+ schema: compileBlueprintSchema(
25
+ {
26
+ handle: def.handle,
27
+ label: def.label,
28
+ singleton: true,
29
+ fields: def.fields,
30
+ },
31
+ options,
32
+ ),
33
+ hash: await hashGlobalSetDefinition(def),
34
+ }
35
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'astro/zod'
2
+ import { sha256Hex } from '../sha256.js'
3
+ import { type FieldDefinition, FieldDefinitionSchema } from '../blueprints/definition.js'
4
+
5
+ export const GlobalSetDefinitionSchema = z.object({
6
+ handle: z.string().regex(/^[a-z][a-z0-9_-]*$/),
7
+ label: z.string().min(1),
8
+ fields: z.array(FieldDefinitionSchema).default([]),
9
+ })
10
+
11
+ export type GlobalSetDefinition = z.infer<typeof GlobalSetDefinitionSchema>
12
+
13
+ export async function hashGlobalSetDefinition(def: GlobalSetDefinition): Promise<string> {
14
+ const canonical = JSON.stringify({
15
+ handle: def.handle,
16
+ label: def.label,
17
+ fields: def.fields.map((f: FieldDefinition) => ({
18
+ name: f.name,
19
+ label: f.label ?? null,
20
+ ui: f.ui,
21
+ optional: f.optional,
22
+ default: f.default ?? null,
23
+ validation: f.validation ?? null,
24
+ })),
25
+ })
26
+ return sha256Hex(canonical)
27
+ }
@@ -0,0 +1,45 @@
1
+ import type { VulseDb } from './db.js'
2
+ import { SettingsRepo } from './repos/settings.js'
3
+ import { DEFAULT_LOCALE } from './repos/entries.js'
4
+
5
+ export const LOCALES_KEY = 'locales'
6
+ export const DEFAULT_LOCALE_KEY = 'defaultLocale'
7
+
8
+ // BCP-47-ish: 2-3 letter base + optional region. Conservative, easy to extend later.
9
+ const LOCALE_CODE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$/
10
+
11
+ export interface LocalesConfig {
12
+ /** Ordered list of locales supported by the site. Always contains defaultLocale. */
13
+ locales: string[]
14
+ /** The locale used when none is specified. */
15
+ defaultLocale: string
16
+ }
17
+
18
+ export function isValidLocaleCode(code: string): boolean {
19
+ return code === DEFAULT_LOCALE || LOCALE_CODE_RE.test(code)
20
+ }
21
+
22
+ export async function readLocalesConfig(db: VulseDb): Promise<LocalesConfig> {
23
+ const repo = new SettingsRepo(db)
24
+ const [raw, def] = await Promise.all([
25
+ repo.get<unknown>(LOCALES_KEY),
26
+ repo.get<string>(DEFAULT_LOCALE_KEY),
27
+ ])
28
+ const locales = Array.isArray(raw)
29
+ ? raw.filter((v): v is string => typeof v === 'string' && isValidLocaleCode(v))
30
+ : []
31
+ const defaultLocale = typeof def === 'string' && isValidLocaleCode(def) ? def : DEFAULT_LOCALE
32
+ if (locales.length === 0) locales.push(defaultLocale)
33
+ if (!locales.includes(defaultLocale)) locales.unshift(defaultLocale)
34
+ return { locales, defaultLocale }
35
+ }
36
+
37
+ /** Validate a locale param against site configuration; throws if unknown. */
38
+ export async function resolveLocale(db: VulseDb, candidate: string | null | undefined): Promise<string> {
39
+ const cfg = await readLocalesConfig(db)
40
+ if (!candidate || candidate === DEFAULT_LOCALE) return cfg.defaultLocale
41
+ if (!cfg.locales.includes(candidate)) {
42
+ throw new Error(`Unknown locale '${candidate}'. Supported: ${cfg.locales.join(', ')}`)
43
+ }
44
+ return candidate
45
+ }
@@ -0,0 +1,48 @@
1
+ import initSql from '../../migrations/0000_init.sql?raw'
2
+ import collectionsSetsSql from '../../migrations/0001_collections_sets.sql?raw'
3
+ import ftsSql from '../../migrations/0003_fts.sql?raw'
4
+ import formsSql from '../../migrations/0004_forms.sql?raw'
5
+ import globalsSql from '../../migrations/0005_globals.sql?raw'
6
+ import previewSessionsSql from '../../migrations/0006_preview_sessions.sql?raw'
7
+
8
+ const MIGRATIONS = [
9
+ { id: '0000_init', sql: initSql },
10
+ { id: '0001_collections_sets', sql: collectionsSetsSql },
11
+ // 0002_tree_drafts was folded into 0000_init when the schema was reshaped for
12
+ // i18n. The ID is intentionally skipped so the ledger remains forward-only.
13
+ { id: '0003_fts', sql: ftsSql },
14
+ { id: '0004_forms', sql: formsSql },
15
+ { id: '0005_globals', sql: globalsSql },
16
+ { id: '0006_preview_sessions', sql: previewSessionsSql },
17
+ ] as const
18
+
19
+ function splitStatements(sql: string): string[] {
20
+ return sql
21
+ .split('--> statement-breakpoint')
22
+ .map((s) => s.trim())
23
+ .filter(Boolean)
24
+ }
25
+
26
+ /** Applies bundled SQL migrations directly to a D1 binding (used in tests and Workers). */
27
+ export async function applyMigrations(db: D1Database): Promise<void> {
28
+ await db.exec(
29
+ 'CREATE TABLE IF NOT EXISTS _vulse_migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)',
30
+ )
31
+
32
+ for (const migration of MIGRATIONS) {
33
+ const applied = await db
34
+ .prepare('SELECT id FROM _vulse_migrations WHERE id = ?')
35
+ .bind(migration.id)
36
+ .first()
37
+ if (applied) continue
38
+
39
+ for (const stmt of splitStatements(migration.sql)) {
40
+ await db.exec(stmt.replace(/\s+/g, ' '))
41
+ }
42
+
43
+ await db
44
+ .prepare('INSERT INTO _vulse_migrations (id, applied_at) VALUES (?, ?)')
45
+ .bind(migration.id, Date.now())
46
+ .run()
47
+ }
48
+ }
@@ -0,0 +1,85 @@
1
+ import type { z } from 'astro/zod'
2
+ import { ValidationError } from './errors.js'
3
+
4
+ export interface ContentValidationIssue {
5
+ path: (string | number)[]
6
+ message: string
7
+ code?: string
8
+ }
9
+
10
+ /**
11
+ * Parses arbitrary content against a Zod schema. On failure, throws a
12
+ * ValidationError whose message names the first offending field (so the
13
+ * top-level error banner reads sensibly) and whose `details.issues` carries
14
+ * the full list with humanized messages for inline field display.
15
+ */
16
+ export function parseContent<S extends z.ZodTypeAny>(schema: S, content: unknown): z.infer<S> {
17
+ const parsed = schema.safeParse(content)
18
+ if (parsed.success) return parsed.data
19
+
20
+ const issues: ContentValidationIssue[] = parsed.error.issues.map((issue) => ({
21
+ path: issue.path.filter((p): p is string | number => typeof p === 'string' || typeof p === 'number'),
22
+ message: humanizeIssue(issue),
23
+ code: issue.code,
24
+ }))
25
+
26
+ throw new ValidationError(summarize(issues), { issues })
27
+ }
28
+
29
+ function summarize(issues: ContentValidationIssue[]): string {
30
+ if (issues.length === 0) return 'Validation failed'
31
+ const first = issues[0]!
32
+ const fieldLabel = first.path.length ? first.path.join('.') : 'Content'
33
+ if (issues.length === 1) return `${fieldLabel}: ${first.message}`
34
+ return `${fieldLabel}: ${first.message} (and ${issues.length - 1} more issue${issues.length - 1 === 1 ? '' : 's'})`
35
+ }
36
+
37
+ interface ZodIssueLike {
38
+ code: string
39
+ message: string
40
+ path: (string | number | symbol)[]
41
+ // Zod 4 fields
42
+ expected?: string
43
+ origin?: string
44
+ minimum?: number | bigint
45
+ maximum?: number | bigint
46
+ inclusive?: boolean
47
+ format?: string
48
+ // Zod 3 fields (kept for compatibility)
49
+ received?: string
50
+ type?: string
51
+ validation?: string
52
+ }
53
+
54
+ function humanizeIssue(issue: unknown): string {
55
+ const i = issue as ZodIssueLike
56
+
57
+ // Missing required value.
58
+ if (i.code === 'invalid_type') {
59
+ if (i.received === 'undefined' || i.received === 'null') return 'This field is required.'
60
+ if (/received (undefined|null)/.test(i.message)) return 'This field is required.'
61
+ if (i.expected) return `Expected ${i.expected}.`
62
+ }
63
+
64
+ // Length / numeric bounds.
65
+ const origin = i.origin ?? i.type
66
+ if (i.code === 'too_small') {
67
+ if (origin === 'string' && Number(i.minimum) === 1) return 'This field is required.'
68
+ if (origin === 'string') return `Must be at least ${i.minimum} characters.`
69
+ if (origin === 'number') return `Must be ${i.inclusive ? 'at least' : 'greater than'} ${i.minimum}.`
70
+ if (origin === 'array') return `Add at least ${i.minimum} item${i.minimum === 1 ? '' : 's'}.`
71
+ }
72
+ if (i.code === 'too_big') {
73
+ if (origin === 'string') return `Must be at most ${i.maximum} characters.`
74
+ if (origin === 'number') return `Must be ${i.inclusive ? 'at most' : 'less than'} ${i.maximum}.`
75
+ if (origin === 'array') return `No more than ${i.maximum} item${i.maximum === 1 ? '' : 's'}.`
76
+ }
77
+
78
+ // String formats (Zod 4: invalid_format with format; Zod 3: invalid_string with validation).
79
+ const fmt = i.format ?? i.validation
80
+ if (fmt === 'email') return 'Enter a valid email address.'
81
+ if (fmt === 'url') return 'Enter a valid URL.'
82
+
83
+ if (i.message === 'Required') return 'This field is required.'
84
+ return i.message
85
+ }