@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.
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +3 -4
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +11 -10
- package/dist/core/blueprints/compile.d.ts +1 -1
- package/dist/core/blueprints/compile.d.ts.map +1 -1
- package/dist/core/blueprints/compile.js +3 -3
- package/dist/core/blueprints/mutations.js +2 -2
- package/dist/core/forms/rate-limit.d.ts +1 -1
- package/dist/core/forms/rate-limit.d.ts.map +1 -1
- package/dist/core/forms/rate-limit.js +3 -3
- package/dist/core/forms/unique.d.ts +1 -1
- package/dist/core/forms/unique.d.ts.map +1 -1
- package/dist/core/forms/unique.js +4 -4
- package/dist/core/globals/compile.d.ts +1 -1
- package/dist/core/globals/compile.d.ts.map +1 -1
- package/dist/core/globals/compile.js +2 -2
- package/dist/core/globals/definition.d.ts +1 -1
- package/dist/core/globals/definition.d.ts.map +1 -1
- package/dist/core/globals/definition.js +3 -3
- package/dist/core/repos/globals.js +3 -3
- package/dist/core/sha256.d.ts +3 -0
- package/dist/core/sha256.d.ts.map +1 -0
- package/dist/core/sha256.js +9 -0
- package/dist/integration/index.js +1 -1
- package/dist/integration/install-hook.d.ts.map +1 -1
- package/dist/integration/install-hook.js +6 -4
- package/dist/integration/wrangler-config.d.ts +12 -0
- package/dist/integration/wrangler-config.d.ts.map +1 -0
- package/dist/integration/wrangler-config.js +97 -0
- package/dist/integration/wrangler-patch.d.ts +1 -0
- package/dist/integration/wrangler-patch.d.ts.map +1 -1
- package/dist/integration/wrangler-patch.js +2 -1
- package/dist/server/routes/form-submit.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +11 -3
- package/src/admin/assets/logo-mark.svg +5 -0
- package/src/admin/client/active-locale.ts +17 -0
- package/src/admin/client/api.ts +21 -0
- package/src/admin/client/form-from-zod.ts +7 -0
- package/src/admin/client/live-preview-enabled.ts +5 -0
- package/src/admin/components/AdminShell.astro +45 -0
- package/src/admin/components/AuthSettings.vue +60 -0
- package/src/admin/components/BlockEditor.vue +53 -0
- package/src/admin/components/BlueprintEditor.vue +1783 -0
- package/src/admin/components/CollectionKindIcon.vue +26 -0
- package/src/admin/components/CollectionTree.vue +220 -0
- package/src/admin/components/EntryEditorWithPreview.vue +130 -0
- package/src/admin/components/EntryForm.vue +411 -0
- package/src/admin/components/EntryList.vue +121 -0
- package/src/admin/components/EntryStatusBadge.vue +24 -0
- package/src/admin/components/FormEditor.vue +233 -0
- package/src/admin/components/FormList.vue +54 -0
- package/src/admin/components/GlobalSetEditor.vue +272 -0
- package/src/admin/components/GlobalSetList.vue +55 -0
- package/src/admin/components/LivePreviewPanel.vue +171 -0
- package/src/admin/components/LoginForm.vue +53 -0
- package/src/admin/components/MediaLibrary.vue +106 -0
- package/src/admin/components/MediaPicker.vue +49 -0
- package/src/admin/components/RevisionDiff.vue +11 -0
- package/src/admin/components/RevisionList.vue +134 -0
- package/src/admin/components/SeoFields.vue +113 -0
- package/src/admin/components/SetEditor.vue +137 -0
- package/src/admin/components/SetList.vue +32 -0
- package/src/admin/components/SettingsForm.vue +189 -0
- package/src/admin/components/SideNav.vue +152 -0
- package/src/admin/components/SubmissionDetail.vue +45 -0
- package/src/admin/components/SubmissionList.vue +89 -0
- package/src/admin/components/ToastHost.vue +33 -0
- package/src/admin/components/TreeRow.vue +163 -0
- package/src/admin/components/UserEditor.vue +186 -0
- package/src/admin/components/UserList.vue +46 -0
- package/src/admin/components/blocks/BlockItem.vue +32 -0
- package/src/admin/components/blocks/BlockToolbar.vue +12 -0
- package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
- package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
- package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
- package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
- package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
- package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
- package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
- package/src/admin/components/fields/BlocksField.vue +123 -0
- package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
- package/src/admin/components/fields/BoolField.vue +10 -0
- package/src/admin/components/fields/DateField.vue +22 -0
- package/src/admin/components/fields/EntriesField.vue +153 -0
- package/src/admin/components/fields/EntryField.vue +138 -0
- package/src/admin/components/fields/EnumField.vue +81 -0
- package/src/admin/components/fields/FieldRenderer.vue +87 -0
- package/src/admin/components/fields/GridField.vue +173 -0
- package/src/admin/components/fields/LinkField.vue +219 -0
- package/src/admin/components/fields/MediaField.vue +69 -0
- package/src/admin/components/fields/NumberField.vue +12 -0
- package/src/admin/components/fields/ObjectField.vue +18 -0
- package/src/admin/components/fields/RefField.vue +170 -0
- package/src/admin/components/fields/RepeaterField.vue +27 -0
- package/src/admin/components/fields/ReplicatorField.vue +121 -0
- package/src/admin/components/fields/TextField.vue +11 -0
- package/src/admin/components/fields/TextareaField.vue +11 -0
- package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
- package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
- package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
- package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
- package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
- package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
- package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
- package/src/admin/components/fields/emoji-extension.ts +54 -0
- package/src/admin/components/fields/link-extension.ts +48 -0
- package/src/admin/components/fields/set-node-utils.ts +115 -0
- package/src/admin/components/fields/url-utils.ts +85 -0
- package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
- package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
- package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
- package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
- package/src/admin/components/fields/vulse-set-extension.ts +66 -0
- package/src/admin/components/fields/vulse-video-extension.ts +65 -0
- package/src/admin/composables/toast.ts +35 -0
- package/src/admin/composables/useEntrySearch.ts +112 -0
- package/src/admin/composables/useSets.ts +31 -0
- package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
- package/src/admin/pages/collections/[name]/[id].astro +90 -0
- package/src/admin/pages/collections/[name]/index.astro +31 -0
- package/src/admin/pages/collections/[name]/new.astro +38 -0
- package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
- package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
- package/src/admin/pages/forms/[handle].astro +9 -0
- package/src/admin/pages/forms/index.astro +7 -0
- package/src/admin/pages/forms/new.astro +7 -0
- package/src/admin/pages/index.astro +36 -0
- package/src/admin/pages/login.astro +14 -0
- package/src/admin/pages/media.astro +8 -0
- package/src/admin/pages/schema/[handle].astro +10 -0
- package/src/admin/pages/schema/new.astro +9 -0
- package/src/admin/pages/settings/auth.astro +9 -0
- package/src/admin/pages/settings/globals/[handle].astro +9 -0
- package/src/admin/pages/settings/globals/index.astro +7 -0
- package/src/admin/pages/settings/globals/new.astro +7 -0
- package/src/admin/pages/settings/index.astro +8 -0
- package/src/admin/pages/settings/sets/[handle].astro +9 -0
- package/src/admin/pages/settings/sets/index.astro +7 -0
- package/src/admin/pages/settings/sets/new.astro +7 -0
- package/src/admin/pages/users/[id].astro +10 -0
- package/src/admin/pages/users/index.astro +8 -0
- package/src/admin/styles/admin.css +166 -0
- package/src/core/access.ts +9 -0
- package/src/core/blocks/schema.ts +66 -0
- package/src/core/blueprints/code-to-definition.ts +156 -0
- package/src/core/blueprints/compile.ts +176 -0
- package/src/core/blueprints/define.ts +12 -0
- package/src/core/blueprints/definition.ts +185 -0
- package/src/core/blueprints/load.ts +144 -0
- package/src/core/blueprints/mutations.ts +236 -0
- package/src/core/blueprints/preview-path.ts +33 -0
- package/src/core/blueprints/reflect-fields.ts +305 -0
- package/src/core/blueprints/registry.ts +14 -0
- package/src/core/blueprints/seed.ts +20 -0
- package/src/core/blueprints/select-helpers.ts +30 -0
- package/src/core/blueprints/seo.ts +180 -0
- package/src/core/blueprints/types.ts +59 -0
- package/src/core/blueprints/zod-helpers.ts +86 -0
- package/src/core/db.ts +11 -0
- package/src/core/errors.ts +34 -0
- package/src/core/forms/compile.ts +84 -0
- package/src/core/forms/definition.ts +102 -0
- package/src/core/forms/rate-limit.ts +52 -0
- package/src/core/forms/unique.ts +38 -0
- package/src/core/globals/compile.ts +35 -0
- package/src/core/globals/definition.ts +27 -0
- package/src/core/locales.ts +45 -0
- package/src/core/migrations.ts +48 -0
- package/src/core/parse-content.ts +85 -0
- package/src/core/plugins/definition.ts +150 -0
- package/src/core/preview-content.ts +21 -0
- package/src/core/repos/entries.ts +504 -0
- package/src/core/repos/forms.ts +270 -0
- package/src/core/repos/globals.ts +179 -0
- package/src/core/repos/media.ts +106 -0
- package/src/core/repos/preview-sessions.ts +108 -0
- package/src/core/repos/revisions.ts +60 -0
- package/src/core/repos/settings.ts +23 -0
- package/src/core/schema.ts +244 -0
- package/src/core/sets/compile.ts +12 -0
- package/src/core/sets/definition.ts +10 -0
- package/src/core/sets/service.ts +82 -0
- package/src/core/sets/validate-tree.ts +57 -0
- package/src/core/sha256.ts +10 -0
- package/src/core/slug.ts +30 -0
- package/src/scaffold/collection-write.ts +83 -0
- package/src/scaffold/collection.ts +277 -0
- package/src/server/assets/live-preview-bridge.content.ts +2 -0
- package/src/server/assets/live-preview-bridge.js +535 -0
- package/src/server/better-auth.ts +82 -0
- package/src/server/cf-images.ts +34 -0
- package/src/server/cron.ts +37 -0
- package/src/server/email.ts +17 -0
- package/src/server/endpoints/api-auth.ts +10 -0
- package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
- package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
- package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
- package/src/server/endpoints/api-vulse-entries.ts +23 -0
- package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
- package/src/server/endpoints/api-vulse-form-public.ts +7 -0
- package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
- package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
- package/src/server/endpoints/api-vulse-forms.ts +12 -0
- package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
- package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
- package/src/server/endpoints/api-vulse-globals.ts +12 -0
- package/src/server/endpoints/api-vulse-media-file.ts +7 -0
- package/src/server/endpoints/api-vulse-media-id.ts +12 -0
- package/src/server/endpoints/api-vulse-media.ts +12 -0
- package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
- package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
- package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions.ts +7 -0
- package/src/server/endpoints/api-vulse-search.ts +7 -0
- package/src/server/endpoints/api-vulse-sets.ts +23 -0
- package/src/server/endpoints/api-vulse-settings.ts +12 -0
- package/src/server/endpoints/api-vulse-users-id.ts +12 -0
- package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
- package/src/server/endpoints/api-vulse-users-role.ts +9 -0
- package/src/server/endpoints/api-vulse-users.ts +7 -0
- package/src/server/endpoints/with-runtime.ts +11 -0
- package/src/server/env.ts +23 -0
- package/src/server/envelope.ts +21 -0
- package/src/server/forms/email.ts +11 -0
- package/src/server/forms/process-submission.ts +95 -0
- package/src/server/forms/queue.ts +25 -0
- package/src/server/forms/templates.ts +24 -0
- package/src/server/forms/webhook.ts +19 -0
- package/src/server/handler.ts +66 -0
- package/src/server/image-probe.ts +35 -0
- package/src/server/loader.ts +54 -0
- package/src/server/plugins.ts +214 -0
- package/src/server/preview.ts +25 -0
- package/src/server/r2.ts +13 -0
- package/src/server/routes/blueprints.ts +62 -0
- package/src/server/routes/entries.ts +255 -0
- package/src/server/routes/form-submit.ts +168 -0
- package/src/server/routes/form-upload.ts +100 -0
- package/src/server/routes/forms.ts +88 -0
- package/src/server/routes/globals-public.ts +30 -0
- package/src/server/routes/globals.ts +93 -0
- package/src/server/routes/media.ts +145 -0
- package/src/server/routes/preview-sessions.ts +76 -0
- package/src/server/routes/preview.ts +36 -0
- package/src/server/routes/revisions.ts +29 -0
- package/src/server/routes/search.ts +31 -0
- package/src/server/routes/sets.ts +40 -0
- package/src/server/routes/settings.ts +24 -0
- package/src/server/routes/users.ts +127 -0
- package/src/server/runtime.ts +99 -0
- package/src/server/sdk/collections.ts +98 -0
- package/src/server/sdk/index.ts +25 -0
- package/src/server/sdk/media.ts +11 -0
- 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 }
|