@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,305 @@
1
+ import { z, type ZodTypeAny } from 'astro/zod'
2
+
3
+ import type { Blueprint } from './types.js'
4
+ import type {
5
+ FieldDefinition,
6
+ FieldUi,
7
+ NestedFieldDefinition,
8
+ ReplicatorSetDefinition,
9
+ SelectOption,
10
+ } from './definition.js'
11
+ import { nestedFieldToDescriptor } from './code-to-definition.js'
12
+ import { normalizeSelectOptions } from './select-helpers.js'
13
+
14
+ export type Widget =
15
+ | 'text'
16
+ | 'textarea'
17
+ | 'number'
18
+ | 'bool'
19
+ | 'date'
20
+ | 'enum'
21
+ | 'ref'
22
+ | 'entry'
23
+ | 'entries'
24
+ | 'link'
25
+ | 'media'
26
+ | 'blocks'
27
+ | 'object'
28
+ | 'repeater'
29
+ | 'grid'
30
+ | 'replicator'
31
+
32
+ export interface FieldDescriptor {
33
+ path: string
34
+ widget: Widget
35
+ required: boolean
36
+ description?: string
37
+ blocksSets?: string[]
38
+ replicatorSets?: ReplicatorSetDefinition[]
39
+ label?: string
40
+ options?: string[]
41
+ selectOptions?: { key: string; label: string }[]
42
+ selectMultiple?: boolean
43
+ selectPlaceholder?: string
44
+ selectClearable?: boolean
45
+ refTarget?: string
46
+ entryCollections?: string[]
47
+ entriesCollections?: string[]
48
+ entriesMax?: number
49
+ linkCollections?: string[]
50
+ gridMinRows?: number
51
+ gridMaxRows?: number
52
+ gridMode?: 'table' | 'stacked'
53
+ gridAddLabel?: string
54
+ children?: FieldDescriptor[]
55
+ itemFields?: FieldDescriptor[]
56
+ }
57
+
58
+ interface ZodDef {
59
+ type: string
60
+ innerType?: ZodTypeAny
61
+ element?: ZodTypeAny
62
+ shape?: Record<string, ZodTypeAny>
63
+ entries?: Record<string, string>
64
+ checks?: unknown[]
65
+ }
66
+
67
+ function unwrap(sch: ZodTypeAny): ZodTypeAny {
68
+ let inner = sch
69
+ for (;;) {
70
+ const def = inner._def as ZodDef
71
+ if (def.type === 'optional' || def.type === 'default') {
72
+ inner = def.innerType!
73
+ continue
74
+ }
75
+ break
76
+ }
77
+ return inner
78
+ }
79
+
80
+ function maxLength(sch: ZodTypeAny): number | undefined {
81
+ const checks = (sch._def as ZodDef).checks ?? []
82
+ for (const check of checks) {
83
+ const zod = (check as { _zod?: { def?: { check?: string; maximum?: number } } })._zod
84
+ if (zod?.def?.check === 'max_length') return zod.def.maximum
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ function parseEntriesTag(tag: string): { collections: string[]; max?: number } {
90
+ const rest = tag.slice('vulse:entries:'.length)
91
+ const lastColon = rest.lastIndexOf(':')
92
+ if (lastColon > 0) {
93
+ const maybeMax = Number(rest.slice(lastColon + 1))
94
+ if (!Number.isNaN(maybeMax) && String(maybeMax) === rest.slice(lastColon + 1)) {
95
+ return {
96
+ collections: rest.slice(0, lastColon).split(',').filter(Boolean),
97
+ max: maybeMax,
98
+ }
99
+ }
100
+ }
101
+ return { collections: rest.split(',').filter(Boolean) }
102
+ }
103
+
104
+ export function reflectFields(schema: z.ZodObject<any>): FieldDescriptor[] {
105
+ const shape = schema.shape as Record<string, ZodTypeAny>
106
+ return Object.entries(shape).map(([path, sch]) => describe(path, sch))
107
+ }
108
+
109
+ function describe(path: string, sch: ZodTypeAny): FieldDescriptor {
110
+ const tag = (sch.description ?? '') as string
111
+ const required = !sch.isOptional()
112
+
113
+ if (tag === 'vulse:media') return { path, widget: 'media', required }
114
+ if (tag.startsWith('vulse:ref:')) {
115
+ return { path, widget: 'ref', required, refTarget: tag.slice('vulse:ref:'.length) }
116
+ }
117
+ if (tag.startsWith('vulse:entry:')) {
118
+ return {
119
+ path,
120
+ widget: 'entry',
121
+ required,
122
+ entryCollections: tag.slice('vulse:entry:'.length).split(',').filter(Boolean),
123
+ }
124
+ }
125
+ if (tag.startsWith('vulse:entries:')) {
126
+ const parsed = parseEntriesTag(tag)
127
+ return {
128
+ path,
129
+ widget: 'entries',
130
+ required,
131
+ entriesCollections: parsed.collections,
132
+ ...(parsed.max !== undefined ? { entriesMax: parsed.max } : {}),
133
+ }
134
+ }
135
+ if (tag.startsWith('vulse:link')) {
136
+ const collections =
137
+ tag.length > 'vulse:link'.length
138
+ ? tag.slice('vulse:link:'.length).split(',').filter(Boolean)
139
+ : undefined
140
+ return {
141
+ path,
142
+ widget: 'link',
143
+ required,
144
+ ...(collections !== undefined ? { linkCollections: collections } : {}),
145
+ }
146
+ }
147
+
148
+ const inner = unwrap(sch)
149
+ const t = (inner._def as ZodDef).type
150
+
151
+ if (t === 'string') {
152
+ const max = maxLength(inner)
153
+ return { path, widget: max && max > 200 ? 'textarea' : 'text', required }
154
+ }
155
+ if (t === 'number') return { path, widget: 'number', required }
156
+ if (t === 'boolean') return { path, widget: 'bool', required }
157
+ if (t === 'date') return { path, widget: 'date', required }
158
+ if (t === 'enum') {
159
+ const entries = (inner._def as ZodDef).entries ?? {}
160
+ const keys = Object.keys(entries)
161
+ return {
162
+ path,
163
+ widget: 'enum',
164
+ required,
165
+ options: keys,
166
+ selectOptions: keys.map((key) => ({ key, label: key })),
167
+ }
168
+ }
169
+ if (t === 'object') {
170
+ return { path, widget: 'object', required, children: reflectFields(inner as z.ZodObject<any>) }
171
+ }
172
+ if (t === 'any' && (tag === 'vulse:blocks' || tag.startsWith('vulse:blocks:'))) {
173
+ return blocksDescriptor(path, required, tag)
174
+ }
175
+ if (t === 'array') {
176
+ if (tag === 'vulse:blocks' || tag.startsWith('vulse:blocks:') || path === 'body') {
177
+ return blocksDescriptor(path, required, tag || 'vulse:blocks')
178
+ }
179
+ const el = (inner._def as ZodDef).element!
180
+ if ((el._def as ZodDef).type === 'object') {
181
+ const itemFields = reflectFields(el as z.ZodObject<any>)
182
+ if (tag === 'vulse:grid') {
183
+ return { path, widget: 'grid', required, itemFields, gridMode: 'table' }
184
+ }
185
+ return { path, widget: 'repeater', required, itemFields }
186
+ }
187
+ if ((el._def as ZodDef).type === 'enum') {
188
+ const entries = (el._def as ZodDef).entries ?? {}
189
+ const keys = Object.keys(entries)
190
+ return {
191
+ path,
192
+ widget: 'enum',
193
+ required,
194
+ options: keys,
195
+ selectOptions: keys.map((key) => ({ key, label: key })),
196
+ selectMultiple: true,
197
+ }
198
+ }
199
+ return { path, widget: 'text', required }
200
+ }
201
+ return { path, widget: 'text', required }
202
+ }
203
+
204
+ const RESERVED_FIELD_NAMES = new Set(['slug', 'status', 'seo'])
205
+
206
+ export function fieldDescriptorsFromDefinitions(fields: FieldDefinition[]): FieldDescriptor[] {
207
+ return fields.map(fieldDefinitionToDescriptor)
208
+ }
209
+
210
+ export function fieldDescriptorsFromBlueprint(bp: Blueprint): FieldDescriptor[] {
211
+ const all = !bp.fields?.length
212
+ ? reflectFields(bp.schema as z.ZodObject<any>)
213
+ : bp.fields.map(fieldDefinitionToDescriptor)
214
+ return all.filter((f) => !RESERVED_FIELD_NAMES.has(f.path))
215
+ }
216
+
217
+ function selectDescriptor(
218
+ base: { path: string; required: boolean; label?: string },
219
+ options: SelectOption[],
220
+ config?: { multiple?: boolean; placeholder?: string; clearable?: boolean },
221
+ ): FieldDescriptor {
222
+ const normalized = normalizeSelectOptions(options)
223
+ return {
224
+ ...base,
225
+ widget: 'enum',
226
+ options: normalized.map((o) => o.key),
227
+ selectOptions: normalized,
228
+ ...(config?.multiple !== undefined ? { selectMultiple: config.multiple } : {}),
229
+ ...(config?.placeholder !== undefined ? { selectPlaceholder: config.placeholder } : {}),
230
+ ...(config?.clearable !== undefined ? { selectClearable: config.clearable } : {}),
231
+ }
232
+ }
233
+
234
+ function fieldDefinitionToDescriptor(f: FieldDefinition): FieldDescriptor {
235
+ const base = { path: f.name, required: !f.optional, label: f.label ?? f.name }
236
+ switch (f.ui.kind) {
237
+ case 'textarea':
238
+ return { ...base, widget: 'textarea' }
239
+ case 'boolean':
240
+ return { ...base, widget: 'bool' }
241
+ case 'date':
242
+ return { ...base, widget: 'date' }
243
+ case 'select':
244
+ return selectDescriptor(base, f.ui.options, {
245
+ ...(f.ui.multiple !== undefined ? { multiple: f.ui.multiple } : {}),
246
+ ...(f.ui.placeholder !== undefined ? { placeholder: f.ui.placeholder } : {}),
247
+ ...(f.ui.clearable !== undefined ? { clearable: f.ui.clearable } : {}),
248
+ })
249
+ case 'relationship':
250
+ return { ...base, widget: 'ref', refTarget: f.ui.to }
251
+ case 'entry':
252
+ return { ...base, widget: 'entry', entryCollections: f.ui.collections }
253
+ case 'entries':
254
+ return {
255
+ ...base,
256
+ widget: 'entries',
257
+ entriesCollections: f.ui.collections,
258
+ ...(f.ui.max !== undefined ? { entriesMax: f.ui.max } : {}),
259
+ }
260
+ case 'link':
261
+ return {
262
+ ...base,
263
+ widget: 'link',
264
+ ...(f.ui.collections !== undefined ? { linkCollections: f.ui.collections } : {}),
265
+ }
266
+ case 'asset':
267
+ return { ...base, widget: 'media' }
268
+ case 'blocks': {
269
+ const tag = f.ui.sets?.length ? `vulse:blocks:${f.ui.sets.join(',')}` : 'vulse:blocks'
270
+ return {
271
+ ...base,
272
+ widget: 'blocks',
273
+ description: tag,
274
+ ...(f.ui.sets?.length ? { blocksSets: f.ui.sets } : {}),
275
+ }
276
+ }
277
+ case 'replicator':
278
+ return { ...base, widget: 'replicator', replicatorSets: f.ui.sets }
279
+ case 'grid':
280
+ return {
281
+ ...base,
282
+ widget: 'grid',
283
+ itemFields: f.ui.fields.map((field) => nestedFieldToDescriptor(field)),
284
+ gridMode: f.ui.mode ?? 'table',
285
+ ...(f.ui.minRows !== undefined ? { gridMinRows: f.ui.minRows } : {}),
286
+ ...(f.ui.maxRows !== undefined ? { gridMaxRows: f.ui.maxRows } : {}),
287
+ ...(f.ui.addLabel !== undefined ? { gridAddLabel: f.ui.addLabel } : {}),
288
+ }
289
+ default:
290
+ return { ...base, widget: 'text' }
291
+ }
292
+ }
293
+
294
+ function blocksDescriptor(path: string, required: boolean, tag: string): FieldDescriptor {
295
+ const blocksSets = tag.startsWith('vulse:blocks:')
296
+ ? tag.slice('vulse:blocks:'.length).split(',').filter(Boolean)
297
+ : []
298
+ return {
299
+ path,
300
+ widget: 'blocks',
301
+ required,
302
+ description: tag,
303
+ ...(blocksSets.length ? { blocksSets } : {}),
304
+ }
305
+ }
@@ -0,0 +1,14 @@
1
+ import type { Blueprint } from './types.js'
2
+
3
+ export class BlueprintRegistry {
4
+ #map = new Map<string, Blueprint>()
5
+
6
+ register(bp: Blueprint): void {
7
+ if (this.#map.has(bp.name)) throw new Error(`Blueprint "${bp.name}" already registered`)
8
+ this.#map.set(bp.name, bp)
9
+ }
10
+
11
+ get(name: string): Blueprint | undefined { return this.#map.get(name) }
12
+ list(): Blueprint[] { return [...this.#map.values()] }
13
+ has(name: string): boolean { return this.#map.has(name) }
14
+ }
@@ -0,0 +1,20 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import type { VulseDb } from '../db.js'
3
+ import { vulseCollections } from '../schema.js'
4
+ import { createBlueprint, getBlueprintDefinition } from './mutations.js'
5
+ import { blueprintToDefinition } from './code-to-definition.js'
6
+ import type { Blueprint } from './types.js'
7
+
8
+ export async function seedCodeBlueprints(db: VulseDb, codeBlueprints: Blueprint[]): Promise<void> {
9
+ for (const bp of codeBlueprints) {
10
+ const existing = await getBlueprintDefinition(db, bp.name)
11
+ if (existing) continue
12
+ const def = blueprintToDefinition(bp)
13
+ await createBlueprint(db, def)
14
+ }
15
+ }
16
+
17
+ export async function listCollectionHandles(db: VulseDb): Promise<string[]> {
18
+ const rows = await db.select({ handle: vulseCollections.handle }).from(vulseCollections)
19
+ return rows.map((r) => r.handle)
20
+ }
@@ -0,0 +1,30 @@
1
+ import type { SelectOption } from './definition.js'
2
+
3
+ export function normalizeSelectOptions(options: SelectOption[]): { key: string; label: string }[] {
4
+ return options.map((o) => (typeof o === 'string' ? { key: o, label: o } : o))
5
+ }
6
+
7
+ export function selectOptionKeys(options: SelectOption[]): [string, ...string[]] {
8
+ const keys = normalizeSelectOptions(options).map((o) => o.key)
9
+ if (keys.length === 0) throw new Error('Select field requires at least one option')
10
+ return keys as [string, ...string[]]
11
+ }
12
+
13
+ /** Parse blueprint editor textarea: `key` or `key: Label` per line. */
14
+ export function parseSelectOptionsText(text: string): SelectOption[] {
15
+ return text
16
+ .split('\n')
17
+ .map((s) => s.trim())
18
+ .filter(Boolean)
19
+ .map((line) => {
20
+ const colon = line.indexOf(':')
21
+ if (colon > 0) {
22
+ return { key: line.slice(0, colon).trim(), label: line.slice(colon + 1).trim() }
23
+ }
24
+ return line
25
+ })
26
+ }
27
+
28
+ export function formatSelectOptionsText(options: SelectOption[]): string {
29
+ return options.map((o) => (typeof o === 'string' ? o : `${o.key}: ${o.label}`)).join('\n')
30
+ }
@@ -0,0 +1,180 @@
1
+ import { z } from 'astro/zod'
2
+ import type { FieldDescriptor } from './reflect-fields.js'
3
+
4
+ export const SEO_FIELD_PATH = 'seo'
5
+
6
+ export interface SeoContent {
7
+ metaTitle?: string
8
+ metaDescription?: string
9
+ ogImage?: string
10
+ }
11
+
12
+ /** Maps blueprint content fields to SEO defaults. Omitted keys use inferred defaults. */
13
+ export interface SeoFieldMapping {
14
+ metaTitle?: string
15
+ metaDescription?: string
16
+ ogImage?: string
17
+ }
18
+
19
+ export const SeoFieldMappingSchema = z.object({
20
+ metaTitle: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/).optional(),
21
+ metaDescription: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/).optional(),
22
+ ogImage: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/).optional(),
23
+ })
24
+
25
+ export interface ResolvedSeoField<T = string> {
26
+ value: T | undefined
27
+ sourceField?: string
28
+ overridden: boolean
29
+ }
30
+
31
+ export interface ResolvedSeo {
32
+ metaTitle: ResolvedSeoField
33
+ metaDescription: ResolvedSeoField
34
+ ogImage: ResolvedSeoField
35
+ }
36
+
37
+ interface BlockNode {
38
+ type?: string
39
+ text?: string
40
+ content?: BlockNode[]
41
+ }
42
+
43
+ export function seoZodSchema(): z.ZodOptional<z.ZodObject<{
44
+ metaTitle: z.ZodOptional<z.ZodString>
45
+ metaDescription: z.ZodOptional<z.ZodString>
46
+ ogImage: z.ZodOptional<z.ZodString>
47
+ }>> {
48
+ return z.object({
49
+ metaTitle: z.string().max(70).optional(),
50
+ metaDescription: z.string().max(160).optional(),
51
+ ogImage: z.string().describe('vulse:media').optional(),
52
+ }).optional()
53
+ }
54
+
55
+ export function applySeoToSchema(
56
+ schema: z.ZodObject<z.ZodRawShape>,
57
+ seo?: boolean,
58
+ ): z.ZodObject<z.ZodRawShape> {
59
+ if (!seo) return schema
60
+ if (SEO_FIELD_PATH in schema.shape) return schema
61
+ return z.object({
62
+ ...schema.shape,
63
+ [SEO_FIELD_PATH]: seoZodSchema(),
64
+ })
65
+ }
66
+
67
+ export function emptySeoContent(): SeoContent {
68
+ return {}
69
+ }
70
+
71
+ function isProseMirrorDoc(v: unknown): v is BlockNode {
72
+ return typeof v === 'object' && v !== null && (v as BlockNode).type === 'doc'
73
+ }
74
+
75
+ function plainTextFromRichContent(value: unknown): string {
76
+ if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim()
77
+ if (!isProseMirrorDoc(value)) return ''
78
+ const parts: string[] = []
79
+ function walk(nodes: BlockNode[] | undefined) {
80
+ for (const node of nodes ?? []) {
81
+ if (node.type === 'text') parts.push(node.text ?? '')
82
+ else walk(node.content)
83
+ }
84
+ }
85
+ walk(value.content)
86
+ return parts.join(' ').replace(/\s+/g, ' ').trim()
87
+ }
88
+
89
+ function truncateDescription(text: string, max = 160): string {
90
+ if (text.length <= max) return text
91
+ const slice = text.slice(0, max)
92
+ const lastSpace = slice.lastIndexOf(' ')
93
+ return (lastSpace > 0 ? slice.slice(0, lastSpace) : slice).trim()
94
+ }
95
+
96
+ export function resolveSeoFieldMapping(
97
+ fields: Array<Pick<FieldDescriptor, 'path' | 'widget'>>,
98
+ titleField: string,
99
+ mapping?: SeoFieldMapping,
100
+ ): SeoFieldMapping {
101
+ const mediaField = fields.find((f) => f.widget === 'media')?.path
102
+ const textareaField = fields.find((f) => f.widget === 'textarea')?.path
103
+ const blocksField = fields.find((f) => f.widget === 'blocks')?.path
104
+ const textField = fields.find((f) => f.widget === 'text' && f.path !== titleField)?.path
105
+
106
+ const resolved: SeoFieldMapping = {
107
+ metaTitle: mapping?.metaTitle ?? titleField,
108
+ }
109
+ const metaDescription = mapping?.metaDescription ?? textareaField ?? blocksField ?? textField
110
+ if (metaDescription) resolved.metaDescription = metaDescription
111
+ const ogImage = mapping?.ogImage ?? mediaField
112
+ if (ogImage) resolved.ogImage = ogImage
113
+ return resolved
114
+ }
115
+
116
+ export function extractSeoFieldValue(
117
+ value: unknown,
118
+ widget: FieldDescriptor['widget'] | undefined,
119
+ ): string | undefined {
120
+ if (value === null || value === undefined) return undefined
121
+ switch (widget) {
122
+ case 'media':
123
+ return typeof value === 'string' && value ? value : undefined
124
+ case 'textarea':
125
+ case 'text':
126
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
127
+ case 'blocks':
128
+ return plainTextFromRichContent(value) || undefined
129
+ default:
130
+ if (typeof value === 'string' && value.trim()) return value.trim()
131
+ return undefined
132
+ }
133
+ }
134
+
135
+ export function resolveEffectiveSeo(
136
+ content: Record<string, unknown>,
137
+ explicit: SeoContent | undefined,
138
+ fields: FieldDescriptor[],
139
+ titleField: string,
140
+ mapping?: SeoFieldMapping,
141
+ ): ResolvedSeo {
142
+ const fieldByPath = new Map(fields.map((f) => [f.path, f]))
143
+ const resolvedMapping = resolveSeoFieldMapping(fields, titleField, mapping)
144
+
145
+ function resolveField(
146
+ key: keyof SeoContent,
147
+ mapKey: keyof SeoFieldMapping,
148
+ transform?: (value: string) => string,
149
+ ): ResolvedSeoField {
150
+ const rawOverride = explicit?.[key]
151
+ if (key === 'ogImage') {
152
+ if (typeof rawOverride === 'string' && rawOverride) {
153
+ return { value: rawOverride, overridden: true }
154
+ }
155
+ } else if (typeof rawOverride === 'string' && rawOverride.trim()) {
156
+ return { value: rawOverride.trim(), overridden: true }
157
+ }
158
+ const sourceField = resolvedMapping[mapKey]
159
+ if (!sourceField) return { value: undefined, overridden: false }
160
+ const widget = fieldByPath.get(sourceField)?.widget
161
+ const raw = extractSeoFieldValue(content[sourceField], widget)
162
+ const value = raw && transform ? transform(raw) : raw
163
+ return { value, sourceField, overridden: false }
164
+ }
165
+
166
+ return {
167
+ metaTitle: resolveField('metaTitle', 'metaTitle'),
168
+ metaDescription: resolveField('metaDescription', 'metaDescription', truncateDescription),
169
+ ogImage: resolveField('ogImage', 'ogImage'),
170
+ }
171
+ }
172
+
173
+ export function resolvedSeoSummary(resolved: ResolvedSeo): string {
174
+ const title = resolved.metaTitle.value?.trim()
175
+ if (title) return title
176
+ const description = resolved.metaDescription.value?.trim()
177
+ if (description) return truncateDescription(description, 70)
178
+ if (resolved.ogImage.value) return 'Image configured'
179
+ return 'No defaults available yet'
180
+ }
@@ -0,0 +1,59 @@
1
+ import type { z } from 'astro/zod'
2
+ import type { BlueprintDefinition, FieldDefinition } from './definition.js'
3
+
4
+ export type Role = 'admin' | 'editor' | 'member'
5
+
6
+ export interface AuthContext {
7
+ user: { id: string; role: Role; email: string } | null
8
+ }
9
+
10
+ export interface AccessArgs<T = unknown> extends AuthContext {
11
+ entry?: { id: string; status: 'draft' | 'published'; createdBy: string | null; content: T }
12
+ }
13
+
14
+ export type AccessFn<T = unknown> = (args: AccessArgs<T>) => boolean | Promise<boolean>
15
+
16
+ import type { SeoFieldMapping } from './seo.js'
17
+
18
+ export interface AdminConfig {
19
+ titleField: string
20
+ listColumns?: string[]
21
+ /** Maps content fields to SEO defaults in the entry editor. */
22
+ seoMapping?: SeoFieldMapping
23
+ }
24
+
25
+ export interface PreviewConfig {
26
+ /**
27
+ * URL template for the public-facing entry page. `{slug}` is replaced with
28
+ * the entry's URL slug. Defaults to `/{slug}` if omitted.
29
+ * Example: `/recipes/{slug}` or `/blog/{slug}`.
30
+ */
31
+ path: string
32
+ /** DOM selector for live preview morph target. Defaults to `main`. */
33
+ rootSelector?: string
34
+ /** When `false`, hides the live preview split panel (Preview button still works). Defaults to `true`. */
35
+ live?: boolean
36
+ }
37
+
38
+ export interface BlueprintAccess<T = unknown> {
39
+ read?: AccessFn<T>
40
+ create?: AccessFn<T>
41
+ update?: AccessFn<T>
42
+ delete?: AccessFn<T>
43
+ }
44
+
45
+ export interface Blueprint<S extends z.ZodTypeAny = z.ZodTypeAny> {
46
+ name: string
47
+ label: string
48
+ schema: S
49
+ admin: AdminConfig
50
+ access?: BlueprintAccess<z.infer<S>>
51
+ preview?: PreviewConfig
52
+ singleton?: boolean
53
+ tree?: boolean
54
+ maxDepth?: number
55
+ drafts?: boolean
56
+ seo?: boolean
57
+ fields?: FieldDefinition[]
58
+ definition?: BlueprintDefinition
59
+ }
@@ -0,0 +1,86 @@
1
+ import { z as astroZ, type ZodRawShape, type ZodTypeAny } from 'astro/zod'
2
+ import { blockSchema } from '../blocks/schema.js'
3
+ import { LinkValueSchema } from './definition.js'
4
+
5
+ export const EMPTY_BLOCKS_DOC = {
6
+ type: 'doc',
7
+ content: [{ type: 'paragraph' }],
8
+ } as const
9
+
10
+ export function blocks(sets?: string[]) {
11
+ const tag = sets?.length ? `vulse:blocks:${sets.join(',')}` : 'vulse:blocks'
12
+ return astroZ.any().default(EMPTY_BLOCKS_DOC).describe(tag)
13
+ }
14
+
15
+ /** Legacy flat block list (deprecated). */
16
+ export function blocksLegacy() {
17
+ return astroZ.array(blockSchema).default([]).describe('vulse:blocks-legacy')
18
+ }
19
+
20
+ /** Media reference: stored as the media row's id; resolved at read time. */
21
+ export function media() {
22
+ return astroZ.string().min(1).describe('vulse:media')
23
+ }
24
+
25
+ /** Reference to another collection (or 'user'). Kept for backward compatibility. */
26
+ export function ref(target: string) {
27
+ return astroZ.string().min(1).describe(`vulse:ref:${target}`)
28
+ }
29
+
30
+ /** Single entry picker from one or more collections. */
31
+ export function entry(...collections: string[]) {
32
+ if (collections.length === 0) throw new Error('entry() requires at least one collection')
33
+ return astroZ.string().min(1).describe(`vulse:entry:${collections.join(',')}`)
34
+ }
35
+
36
+ /** Multi-entry picker from one or more collections. */
37
+ export function entries(collections: string[], max?: number) {
38
+ if (collections.length === 0) throw new Error('entries() requires at least one collection')
39
+ let schema = astroZ.array(astroZ.string().min(1))
40
+ if (max !== undefined) schema = schema.max(max)
41
+ const tag =
42
+ max !== undefined
43
+ ? `vulse:entries:${collections.join(',')}:${max}`
44
+ : `vulse:entries:${collections.join(',')}`
45
+ return schema.describe(tag)
46
+ }
47
+
48
+ /** URL, entry, or first-child link value. */
49
+ export function link(collections?: string[]) {
50
+ const tag = collections?.length ? `vulse:link:${collections.join(',')}` : 'vulse:link'
51
+ return LinkValueSchema.describe(tag)
52
+ }
53
+
54
+ /** Homogeneous row grid (array of objects with fixed columns). */
55
+ export function grid<T extends ZodRawShape>(
56
+ fields: T,
57
+ opts?: { minRows?: number; maxRows?: number },
58
+ ) {
59
+ let schema = astroZ.array(astroZ.object(fields))
60
+ if (opts?.minRows !== undefined) schema = schema.min(opts.minRows)
61
+ if (opts?.maxRows !== undefined) schema = schema.max(opts.maxRows)
62
+ return schema.describe('vulse:grid')
63
+ }
64
+
65
+ export type VulseZ = typeof astroZ & {
66
+ media: typeof media
67
+ ref: typeof ref
68
+ entry: typeof entry
69
+ entries: typeof entries
70
+ link: typeof link
71
+ grid: typeof grid
72
+ }
73
+
74
+ export const z: VulseZ = new Proxy(astroZ, {
75
+ get(target, prop, receiver) {
76
+ if (prop === 'media') return media
77
+ if (prop === 'ref') return ref
78
+ if (prop === 'entry') return entry
79
+ if (prop === 'entries') return entries
80
+ if (prop === 'link') return link
81
+ if (prop === 'grid') return grid
82
+ return Reflect.get(target, prop, receiver)
83
+ },
84
+ }) as VulseZ
85
+
86
+ export type { ZodTypeAny }