kilo-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { emptyRichText, type RichTextDocument, type RichTextNode } from '../richtext'
|
|
2
|
+
import { fallbackLocaleConfig, type LocaleConfig } from './locale'
|
|
3
|
+
import type { AnyFieldConfig, EntryConfig } from './types'
|
|
4
|
+
|
|
5
|
+
// Set once by `defineKiloConfig({ currency })` — a live ES-module binding, same pattern as
|
|
6
|
+
// `fallbackLocaleConfig` in ./locale. Defaults to USD/en-US rather than assuming any one
|
|
7
|
+
// host's currency.
|
|
8
|
+
export let currencyConfig = { locale: 'en-US', currency: 'USD' }
|
|
9
|
+
|
|
10
|
+
/** Called by `defineKiloConfig()` — not meant to be called directly from app code. */
|
|
11
|
+
export function __setCurrencyConfig(config: { locale: string; currency: string }) {
|
|
12
|
+
currencyConfig = config
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Value written when a field is absent/blank, or when a role lacks field permission on create.
|
|
16
|
+
* Also used to build a fresh row's default value for each sub-field of an `array`/`blocks`/
|
|
17
|
+
* `group` field. `localeConfig` defaults to the static fallback — pass the live admin-configured
|
|
18
|
+
* one (from `getLocaleConfig()`/`useCmsLocales()`) wherever it's actually available. */
|
|
19
|
+
export function blankValueForField(field: AnyFieldConfig, localeConfig: LocaleConfig = fallbackLocaleConfig): unknown {
|
|
20
|
+
// Checked first (and recurses into the non-localized blank per locale) — 'localized' only
|
|
21
|
+
// exists on FieldBase, never on an array/block item field, hence the 'in' guard.
|
|
22
|
+
if ('localized' in field && field.localized) {
|
|
23
|
+
const perLocale = blankValueForField({ ...field, localized: false } as AnyFieldConfig, localeConfig)
|
|
24
|
+
return Object.fromEntries(localeConfig.codes.map((locale) => [locale, perLocale]))
|
|
25
|
+
}
|
|
26
|
+
if (field.nullable) return null
|
|
27
|
+
switch (field.type) {
|
|
28
|
+
case 'text':
|
|
29
|
+
case 'textarea':
|
|
30
|
+
case 'image':
|
|
31
|
+
case 'file':
|
|
32
|
+
return ''
|
|
33
|
+
case 'color':
|
|
34
|
+
return '#000000'
|
|
35
|
+
case 'number':
|
|
36
|
+
case 'rating':
|
|
37
|
+
return 0
|
|
38
|
+
case 'boolean':
|
|
39
|
+
return false
|
|
40
|
+
case 'select':
|
|
41
|
+
return field.options[0]?.value ?? ''
|
|
42
|
+
case 'date':
|
|
43
|
+
case 'time':
|
|
44
|
+
case 'datetime':
|
|
45
|
+
return null
|
|
46
|
+
case 'richtext':
|
|
47
|
+
return emptyRichText
|
|
48
|
+
case 'relation':
|
|
49
|
+
return field.hasMany ? [] : ''
|
|
50
|
+
case 'tags':
|
|
51
|
+
case 'multiselect':
|
|
52
|
+
case 'array':
|
|
53
|
+
case 'blocks':
|
|
54
|
+
case 'join':
|
|
55
|
+
return []
|
|
56
|
+
case 'group':
|
|
57
|
+
return Object.fromEntries(field.fields.map((subfield) => [subfield.key, blankValueForField(subfield)]))
|
|
58
|
+
case 'json':
|
|
59
|
+
return {}
|
|
60
|
+
case 'point':
|
|
61
|
+
return { lat: 0, lng: 0 }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Evaluates a field's `visibleIf` against the record it lives in (or, for an array/block item
|
|
66
|
+
* field, the row it lives in) — `field` names a sibling key in that same object. Used both
|
|
67
|
+
* client-side (hide the control) and server-side (relax `required` when not visible). */
|
|
68
|
+
export function isFieldVisible(field: AnyFieldConfig, values: Record<string, unknown>): boolean {
|
|
69
|
+
if (!field.visibleIf) return true
|
|
70
|
+
const condition = field.visibleIf
|
|
71
|
+
const value = values[condition.field]
|
|
72
|
+
if ('equals' in condition) return value === condition.equals
|
|
73
|
+
if ('notEquals' in condition) return value !== condition.notEquals
|
|
74
|
+
if ('in' in condition) return condition.in.includes(value)
|
|
75
|
+
if ('gt' in condition) return Number(value) > condition.gt
|
|
76
|
+
if ('gte' in condition) return Number(value) >= condition.gte
|
|
77
|
+
if ('lt' in condition) return Number(value) < condition.lt
|
|
78
|
+
return Number(value) <= condition.lte
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Flattens a tiptap doc to plain text for list cells, previews, and required-ness checks. */
|
|
82
|
+
export function richTextToText(value: unknown, limit = 120): string {
|
|
83
|
+
const doc = value as RichTextDocument | null | undefined
|
|
84
|
+
if (!doc || doc.type !== 'doc' || !Array.isArray(doc.content)) return ''
|
|
85
|
+
const parts: string[] = []
|
|
86
|
+
const walk = (nodes: RichTextNode[]) => {
|
|
87
|
+
for (const node of nodes) {
|
|
88
|
+
if (typeof node.text === 'string') parts.push(node.text)
|
|
89
|
+
if (Array.isArray(node.content)) walk(node.content)
|
|
90
|
+
if (parts.join(' ').length > limit) return
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
walk(doc.content)
|
|
94
|
+
const text = parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
95
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Blank record used by the "new record" editor state. */
|
|
99
|
+
export function emptyRecord(config: EntryConfig, localeConfig: LocaleConfig = fallbackLocaleConfig): Record<string, unknown> {
|
|
100
|
+
return Object.fromEntries(config.fields.map((field) => [field.key, blankValueForField(field, localeConfig)]))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Human-readable cell/summary text. `relationLabels` is an id -> label map for the field's
|
|
104
|
+
* target collection (covers both single and `hasMany` relations); the caller resolves it once
|
|
105
|
+
* per field (e.g. via `labelsForIds`), not per row. Also used to render an array/group field's
|
|
106
|
+
* contents in read-only mode. */
|
|
107
|
+
export function formatFieldValue(field: AnyFieldConfig, value: unknown, relationLabels?: Record<string, string>, localeConfig: LocaleConfig = fallbackLocaleConfig): string {
|
|
108
|
+
if ('localized' in field && field.localized) {
|
|
109
|
+
const perLocale = (value ?? {}) as Record<string, unknown>
|
|
110
|
+
return formatFieldValue({ ...field, localized: false } as AnyFieldConfig, perLocale[localeConfig.default], relationLabels, localeConfig)
|
|
111
|
+
}
|
|
112
|
+
if (field.type === 'relation') {
|
|
113
|
+
const ids = field.hasMany ? (Array.isArray(value) ? (value as string[]) : []) : (typeof value === 'string' && value ? [value] : [])
|
|
114
|
+
return ids.length ? ids.map((id) => relationLabels?.[id] ?? id.slice(0, 8)).join(', ') : '—'
|
|
115
|
+
}
|
|
116
|
+
if (field.type === 'join') {
|
|
117
|
+
const rows = Array.isArray(value) ? (value as Array<{ label?: unknown }>) : []
|
|
118
|
+
return rows.length ? rows.map((row) => String(row.label ?? '?')).join(', ') : '—'
|
|
119
|
+
}
|
|
120
|
+
if (field.type === 'group') return field.fields.map((subfield) => formatFieldValue(subfield, (value as Record<string, unknown> | undefined)?.[subfield.key])).filter((text) => text && text !== '—').join(', ') || '—'
|
|
121
|
+
if (field.type === 'json') return value && typeof value === 'object' ? (Array.isArray(value) ? `[${value.length}]` : '{…}') : String(value ?? '—')
|
|
122
|
+
if (field.type === 'point') { const point = value as { lat?: number; lng?: number } | null; return point && typeof point.lat === 'number' ? `${point.lat}, ${point.lng}` : '—' }
|
|
123
|
+
if (field.type === 'file') return typeof value === 'string' && value ? value.split('/').pop() || value : '—'
|
|
124
|
+
if (value === null || value === undefined || value === '') return '—'
|
|
125
|
+
if (field.type === 'boolean') return value ? 'Yes' : 'No'
|
|
126
|
+
if (field.type === 'number')
|
|
127
|
+
return field.display === 'currency'
|
|
128
|
+
? new Intl.NumberFormat(currencyConfig.locale, { style: 'currency', currency: currencyConfig.currency, maximumFractionDigits: 0 }).format(Number(value))
|
|
129
|
+
: field.display === 'percentage'
|
|
130
|
+
? `${value}%`
|
|
131
|
+
: String(value)
|
|
132
|
+
if (field.type === 'rating') {
|
|
133
|
+
const max = field.max ?? 5
|
|
134
|
+
const filled = Math.max(0, Math.min(Number(value), max))
|
|
135
|
+
return '★'.repeat(filled) + '☆'.repeat(max - filled)
|
|
136
|
+
}
|
|
137
|
+
if (field.type === 'datetime') return new Date(String(value)).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
|
|
138
|
+
if (field.type === 'select') return field.options.find((option) => option.value === value)?.label ?? String(value)
|
|
139
|
+
if (field.type === 'richtext') return richTextToText(value, 80) || '—'
|
|
140
|
+
if (field.type === 'tags') return Array.isArray(value) && value.length ? (value as string[]).join(', ') : '—'
|
|
141
|
+
if (field.type === 'image') return typeof value === 'string' && value ? value : '—'
|
|
142
|
+
if (field.type === 'time') return String(value).slice(0, 5)
|
|
143
|
+
if (field.type === 'multiselect')
|
|
144
|
+
return Array.isArray(value) && value.length
|
|
145
|
+
? (value as string[]).map((item) => field.options.find((option) => option.value === item)?.label ?? item).join(', ')
|
|
146
|
+
: '—'
|
|
147
|
+
if (field.type === 'array')
|
|
148
|
+
return Array.isArray(value) && value.length ? `${value.length} ${value.length === 1 ? 'item' : 'items'}` : '—'
|
|
149
|
+
if (field.type === 'blocks')
|
|
150
|
+
return Array.isArray(value) && value.length
|
|
151
|
+
? (value as Array<{ blockType?: unknown }>)
|
|
152
|
+
.map((row) => field.blockTypes.find((def) => def.type === row.blockType)?.label ?? String(row.blockType ?? '?'))
|
|
153
|
+
.join(', ')
|
|
154
|
+
: '—'
|
|
155
|
+
if (field.type === 'text' && field.format === 'password') return '••••••••' // never echo the value
|
|
156
|
+
return String(value)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function slugify(value: string): string {
|
|
160
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Fills blank `format: 'slug'` fields: from `slugFrom` when declared, else `draft-<id>`.
|
|
164
|
+
* Reproduces the hand-written `slug: data.slug || `draft-${id}`` fallback exactly, and lets a
|
|
165
|
+
* collection like `categories` be created from a bare `{ name }` body. */
|
|
166
|
+
export function applySlugFallbacks(config: EntryConfig, data: Record<string, unknown>, id: string): Record<string, unknown> {
|
|
167
|
+
const next = { ...data }
|
|
168
|
+
for (const field of config.fields) {
|
|
169
|
+
if (field.type !== 'text' || field.format !== 'slug' || field.readOnly) continue
|
|
170
|
+
const current = next[field.key]
|
|
171
|
+
if (typeof current === 'string' && current) continue
|
|
172
|
+
const source = field.slugFrom ? next[field.slugFrom] : null
|
|
173
|
+
next[field.key] = (typeof source === 'string' ? slugify(source) : '') || `draft-${id}`
|
|
174
|
+
}
|
|
175
|
+
return next
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** ':<fieldKey>' segments substituted from the row, e.g. '/services/:slug'. */
|
|
179
|
+
export function resolvePreviewPath(template: string, row: Record<string, unknown>): string {
|
|
180
|
+
return template.replace(/:([a-zA-Z0-9_]+)/g, (_, key: string) => encodeURIComponent(String(row[key] ?? '')))
|
|
181
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { contentRequiresApproval } from '../publishing-workflow-db'
|
|
4
|
+
import { triggerWebhooks } from '../webhooks'
|
|
5
|
+
import type { EntryConfig } from './types'
|
|
6
|
+
import type { Row } from './query'
|
|
7
|
+
|
|
8
|
+
export type ReviewStatus = 'draft' | 'in_review' | 'reviewed' | 'approved'
|
|
9
|
+
|
|
10
|
+
export async function entryRequiresApproval(config: EntryConfig): Promise<boolean> {
|
|
11
|
+
const workflow = config.workflow
|
|
12
|
+
if (!workflow?.review) return false
|
|
13
|
+
if (workflow.approval === 'always') return true
|
|
14
|
+
if (workflow.approval === 'never') return false
|
|
15
|
+
return contentRequiresApproval(config.slug)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function creationWorkflowValues(config: EntryConfig, userId: string, published: boolean, sortOrder: number): Row {
|
|
19
|
+
const workflow = config.workflow
|
|
20
|
+
if (!workflow) return {}
|
|
21
|
+
return {
|
|
22
|
+
...(workflow.audit ? { createdBy: userId, ownerId: userId, updatedBy: userId } : {}),
|
|
23
|
+
...(workflow.review ? { reviewStatus: 'draft' } : {}),
|
|
24
|
+
published,
|
|
25
|
+
publishedAt: published ? new Date() : null,
|
|
26
|
+
...(workflow.sortOrder ? { sortOrder } : {}),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function updateWorkflowValues(config: EntryConfig, userId: string, published: boolean | null): Row {
|
|
31
|
+
const workflow = config.workflow
|
|
32
|
+
if (!workflow) return {}
|
|
33
|
+
return {
|
|
34
|
+
...(workflow.audit ? { updatedBy: userId } : {}),
|
|
35
|
+
...(published === null
|
|
36
|
+
? {}
|
|
37
|
+
: {
|
|
38
|
+
published,
|
|
39
|
+
// Publishing always clears a pending schedule — matches blog/[id]/route.ts's PATCH.
|
|
40
|
+
...(published ? { publishedAt: new Date(), ...(workflow.scheduling ? { scheduledAt: null } : {}) } : {}),
|
|
41
|
+
}),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function duplicateResetValues(config: EntryConfig, userId: string, sortOrder: number): Row {
|
|
46
|
+
const workflow = config.workflow
|
|
47
|
+
if (!workflow) return {}
|
|
48
|
+
return {
|
|
49
|
+
published: false,
|
|
50
|
+
publishedAt: null,
|
|
51
|
+
...(workflow.scheduling ? { scheduledAt: null } : {}),
|
|
52
|
+
...(workflow.trash ? { deletedAt: null } : {}),
|
|
53
|
+
...(workflow.audit ? { createdBy: userId, ownerId: userId, updatedBy: userId } : {}),
|
|
54
|
+
...(workflow.review ? { reviewStatus: 'draft', reviewerId: null, approverId: null, reviewedAt: null, approvedAt: null } : {}),
|
|
55
|
+
...(workflow.sortOrder ? { sortOrder } : {}),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Scheduling a row also forces it out of `published` — matches the hand-written blog editor,
|
|
60
|
+
* whose schedule() always sends `published: false`. */
|
|
61
|
+
export function scheduleValues(config: EntryConfig, when: Date, userId: string): Row {
|
|
62
|
+
if (!config.workflow?.scheduling) return {}
|
|
63
|
+
return { scheduledAt: when, published: false, ...(config.workflow.audit ? { updatedBy: userId } : {}) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function unscheduleValues(config: EntryConfig, userId: string): Row {
|
|
67
|
+
if (!config.workflow?.scheduling) return {}
|
|
68
|
+
return { scheduledAt: null, ...(config.workflow.audit ? { updatedBy: userId } : {}) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const transitions = {
|
|
72
|
+
submitReview: (reviewerId: string, userId: string) => ({
|
|
73
|
+
reviewStatus: 'in_review',
|
|
74
|
+
reviewerId,
|
|
75
|
+
approverId: null,
|
|
76
|
+
reviewedAt: null,
|
|
77
|
+
approvedAt: null,
|
|
78
|
+
updatedBy: userId,
|
|
79
|
+
}),
|
|
80
|
+
passReview: (userId: string) => ({ reviewStatus: 'reviewed', reviewedAt: new Date(), updatedBy: userId }),
|
|
81
|
+
requestChanges: (userId: string) => ({ reviewStatus: 'draft', reviewerId: null, reviewedAt: null, updatedBy: userId }),
|
|
82
|
+
approve: (userId: string) => ({ reviewStatus: 'approved', approverId: userId, approvedAt: new Date(), updatedBy: userId }),
|
|
83
|
+
trash: (userId: string) => ({ deletedAt: new Date(), published: false, updatedBy: userId }),
|
|
84
|
+
restore: () => ({ deletedAt: null }),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function fireWebhook(config: EntryConfig, event: 'created' | 'updated' | 'published' | 'unpublished' | 'deleted', row: unknown) {
|
|
88
|
+
if (!config.workflow?.webhooks) return
|
|
89
|
+
void triggerWebhooks(`${config.slug}.${event}`, row)
|
|
90
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import { __registerCollections, __registerSingles } from './collections/registry'
|
|
3
|
+
import { __setLocaleConfig, type LocaleConfig } from './collections/locale'
|
|
4
|
+
import { __setCurrencyConfig } from './collections/values'
|
|
5
|
+
import { __recomputeContentTypes } from './content-types'
|
|
6
|
+
import { __setDb } from './db'
|
|
7
|
+
import type { CollectionConfig, SingleConfig } from './collections/types'
|
|
8
|
+
|
|
9
|
+
export type KiloConfig = {
|
|
10
|
+
/** The host's own drizzle instance — created against the host's own DATABASE_URL, with a
|
|
11
|
+
* schema that merges `kilo-cms/schema` with the host's own content tables. */
|
|
12
|
+
db: unknown
|
|
13
|
+
/** Every collection this host defines, via `defineCollectionFields()` — see
|
|
14
|
+
* kilo-cms/collections. Order here is the sidebar order. */
|
|
15
|
+
collections?: CollectionConfig[]
|
|
16
|
+
singles?: SingleConfig[]
|
|
17
|
+
locales?: LocaleConfig
|
|
18
|
+
currency?: { locale: string; currency: string }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The one call a host app makes to wire everything up — collections, the database
|
|
22
|
+
* connection, locales, currency. Call it once, at app startup, BEFORE any request handler or
|
|
23
|
+
* admin page runs (e.g. import it at the top of your root layout, or from a Next.js
|
|
24
|
+
* `instrumentation.ts` `register()` hook). See apps/site/kilo.config.ts for the reference
|
|
25
|
+
* host wiring, and this repo's own `kilo-cms` package docs for the full config shape. */
|
|
26
|
+
export function defineKiloConfig(config: KiloConfig) {
|
|
27
|
+
__setDb(config.db)
|
|
28
|
+
if (config.collections?.length) __registerCollections(config.collections)
|
|
29
|
+
if (config.singles?.length) __registerSingles(config.singles)
|
|
30
|
+
__recomputeContentTypes()
|
|
31
|
+
if (config.locales) __setLocaleConfig(config.locales)
|
|
32
|
+
if (config.currency) __setCurrencyConfig(config.currency)
|
|
33
|
+
return config
|
|
34
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { entries, entrySlugs, isCollectionSlug } from './collections/registry'
|
|
2
|
+
import { blankValueForField } from './collections/values'
|
|
3
|
+
import { workflowColumnKeys, type EntryConfig, type EntrySlug } from './collections/types'
|
|
4
|
+
|
|
5
|
+
// submit_review/review/approve only ever appear in a type's `actions` (and are only grantable)
|
|
6
|
+
// when that type has `workflow.review` — see `entryActions`. Previously a separate global
|
|
7
|
+
// `StoredRolePermissions.workflow` array; folded into the same per-content-type actions/fields
|
|
8
|
+
// structure as create/read/update/delete/publish so "review Blog but not Services" is possible.
|
|
9
|
+
export type ActionKey = 'create' | 'read' | 'update' | 'delete' | 'publish' | 'submit_review' | 'review' | 'approve'
|
|
10
|
+
|
|
11
|
+
// `settings` is the last hand-written content type: cms_site_settings stores cmsAppearance and
|
|
12
|
+
// publishingWorkflow as bespoke jsonb blobs that no engine field type can render. Everything
|
|
13
|
+
// else — including homepage, now the `homepage` single type — is generated from the registry.
|
|
14
|
+
export type CoreContentTypeKey = 'settings'
|
|
15
|
+
export type ContentTypeKey = CoreContentTypeKey | EntrySlug
|
|
16
|
+
export type FieldDef = { key: string; label: string }
|
|
17
|
+
export type ContentTypeDef = { label: string; kind: 'collection' | 'single'; actions: ActionKey[]; fields: FieldDef[] }
|
|
18
|
+
|
|
19
|
+
// A generic slug may never be 'settings' — the spread in `contentTypeDefs` below would
|
|
20
|
+
// silently shadow the hand-written settings content type.
|
|
21
|
+
type NoSlugCollision = Extract<EntrySlug, CoreContentTypeKey> extends never ? true : ['slug collides with a core content type key']
|
|
22
|
+
const _assertNoSlugCollision: NoSlugCollision = true
|
|
23
|
+
void _assertNoSlugCollision
|
|
24
|
+
|
|
25
|
+
const coreContentTypeDefs: Record<CoreContentTypeKey, ContentTypeDef> = {
|
|
26
|
+
settings: {
|
|
27
|
+
label: 'Settings', kind: 'single', actions: ['read', 'update'],
|
|
28
|
+
fields: [
|
|
29
|
+
{ key: 'siteName', label: 'Site name' },
|
|
30
|
+
{ key: 'logoLight', label: 'Logo (light mode)' },
|
|
31
|
+
{ key: 'logoDark', label: 'Logo (dark mode)' },
|
|
32
|
+
{ key: 'metaTitle', label: 'Default meta title' },
|
|
33
|
+
{ key: 'metaDescription', label: 'Default meta description' },
|
|
34
|
+
{ key: 'metaImage', label: 'Default social image' },
|
|
35
|
+
{ key: 'cmsAppearance', label: 'CMS appearance' },
|
|
36
|
+
{ key: 'publishingWorkflow', label: 'Publishing workflow' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const genericCollectionActions: ActionKey[] = ['create', 'read', 'update', 'delete']
|
|
42
|
+
const genericWorkflowCollectionActions: ActionKey[] = ['create', 'read', 'update', 'delete', 'publish']
|
|
43
|
+
const genericSingleActions: ActionKey[] = ['read', 'update']
|
|
44
|
+
const genericWorkflowSingleActions: ActionKey[] = ['read', 'update', 'publish']
|
|
45
|
+
|
|
46
|
+
function entryActions(config: EntryConfig): ActionKey[] {
|
|
47
|
+
const publish = Boolean(config.workflow?.publish)
|
|
48
|
+
const actions: ActionKey[] = config.kind === 'single'
|
|
49
|
+
? [...(publish ? genericWorkflowSingleActions : genericSingleActions)]
|
|
50
|
+
: [...(publish ? genericWorkflowCollectionActions : genericCollectionActions)]
|
|
51
|
+
if (config.workflow?.review) actions.push('submit_review', 'review', 'approve')
|
|
52
|
+
return actions
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function entryContentTypeDef(config: EntryConfig): ContentTypeDef {
|
|
56
|
+
return {
|
|
57
|
+
label: config.label,
|
|
58
|
+
kind: config.kind,
|
|
59
|
+
actions: entryActions(config),
|
|
60
|
+
// `join` has no backing column — never writable, so it's meaningless as a field permission.
|
|
61
|
+
fields: config.fields
|
|
62
|
+
.filter((field) => !field.readOnly && field.type !== 'join' && !(workflowColumnKeys as readonly string[]).includes(field.key))
|
|
63
|
+
.map((field) => ({ key: field.key, label: field.label })),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `contentTypeDefs`/`contentTypeKeys`/`blankFieldValues` below are live ES-module bindings,
|
|
68
|
+
// recomputed by `__recomputeContentTypes()` — called once by `defineKiloConfig()` after it
|
|
69
|
+
// registers the host's collections/singles. They start as just the hand-written `settings`
|
|
70
|
+
// type; every importer sees the real, full set automatically once config has run, the same
|
|
71
|
+
// live-binding pattern `./collections/registry` uses for `entrySlugs`/`entries`/etc.
|
|
72
|
+
export let contentTypeDefs: Record<ContentTypeKey, ContentTypeDef> = { ...coreContentTypeDefs }
|
|
73
|
+
export let contentTypeKeys: ContentTypeKey[] = Object.keys(contentTypeDefs) as ContentTypeKey[]
|
|
74
|
+
|
|
75
|
+
export const systemPermissions = ['users.manage', 'roles.manage', 'ai.manage', 'views.manage', 'developers.manage', 'locales.manage'] as const
|
|
76
|
+
export type SystemPermission = typeof systemPermissions[number]
|
|
77
|
+
export const systemPermissionLabels: Record<SystemPermission, string> = {
|
|
78
|
+
'users.manage': 'Manage users',
|
|
79
|
+
'roles.manage': 'Manage roles',
|
|
80
|
+
'ai.manage': 'Manage AI provider keys',
|
|
81
|
+
'views.manage': 'Manage View settings (field layout)',
|
|
82
|
+
'developers.manage': 'Manage email, API tokens & webhooks',
|
|
83
|
+
'locales.manage': 'Manage locales (for localized fields)',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const actionLabels: Record<ActionKey, string> = {
|
|
87
|
+
create: 'Create', read: 'Read', update: 'Update', delete: 'Delete / Trash', publish: 'Publish',
|
|
88
|
+
submit_review: 'Submit for review', review: 'Review submissions', approve: 'Approve for publish',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type StoredRolePermissions = {
|
|
92
|
+
contentTypes: Partial<Record<ContentTypeKey, { actions: ActionKey[]; fields: string[] }>>
|
|
93
|
+
system: SystemPermission[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const emptyRolePermissions: StoredRolePermissions = { contentTypes: {}, system: [] }
|
|
97
|
+
|
|
98
|
+
export function fullRolePermissions(): StoredRolePermissions {
|
|
99
|
+
return {
|
|
100
|
+
contentTypes: Object.fromEntries(contentTypeKeys.map((key) => [key, { actions: [...contentTypeDefs[key].actions], fields: contentTypeDefs[key].fields.map((field) => field.key) }])) as StoredRolePermissions['contentTypes'],
|
|
101
|
+
system: [...systemPermissions],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Blank defaults used server-side when a role lacks permission for a field on CREATE
|
|
106
|
+
// (the submitted value is dropped in favor of this instead of the row's — nonexistent —
|
|
107
|
+
// prior value).
|
|
108
|
+
// `approvalRequired` used to hand-list this app's own collection slugs (`['services',
|
|
109
|
+
// 'projects', 'blog']`) — computed instead, from whichever registered collections actually
|
|
110
|
+
// declare `workflow.review`, so the package never needs to know a host's specific slugs.
|
|
111
|
+
function coreBlankFieldValues(): Record<CoreContentTypeKey, Record<string, unknown>> {
|
|
112
|
+
return {
|
|
113
|
+
settings: { siteName: 'Kilo CMS', logoLight: null, logoDark: null, metaTitle: '', metaDescription: '', metaImage: null, cmsAppearance: { preset: 'acid', light: {}, dark: {} }, publishingWorkflow: { approvalRequired: entrySlugs.filter((slug) => entries[slug].workflow?.review) } },
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export let blankFieldValues: Record<ContentTypeKey, Record<string, unknown>> = { ...coreBlankFieldValues() }
|
|
118
|
+
|
|
119
|
+
// Overwrites any field the caller isn't allowed to change with a fallback value
|
|
120
|
+
// (the row's existing value on update, or a static blank on create).
|
|
121
|
+
export function applyFieldPermissions<T extends Record<string, unknown>>(type: ContentTypeKey, data: T, allowedFields: string[], fallback: Record<string, unknown>): T {
|
|
122
|
+
const result: Record<string, unknown> = { ...data }
|
|
123
|
+
for (const field of contentTypeDefs[type].fields) {
|
|
124
|
+
if (!allowedFields.includes(field.key) && field.key in result && field.key in fallback) result[field.key] = fallback[field.key]
|
|
125
|
+
}
|
|
126
|
+
return result as T
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Same shape as `applyFieldPermissions`, but for READ: a field the caller has no permission for
|
|
130
|
+
// is removed from the response entirely (not just blanked) — closes the "create/update are
|
|
131
|
+
// per-field, read is all-or-nothing" gap, and doesn't even reveal that the field has SOME value
|
|
132
|
+
// (blanking would still leak "this row has this field set to something", just not what).
|
|
133
|
+
export function redactFieldsForRead<T extends Record<string, unknown>>(type: ContentTypeKey, data: T, allowedFields: string[]): T {
|
|
134
|
+
const result: Record<string, unknown> = { ...data }
|
|
135
|
+
for (const field of contentTypeDefs[type].fields) {
|
|
136
|
+
if (!allowedFields.includes(field.key) && field.key in result) delete result[field.key]
|
|
137
|
+
}
|
|
138
|
+
return result as T
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Called once by `defineKiloConfig()`, after it registers the host's collections/singles —
|
|
142
|
+
* not meant to be called directly from app code. Recomputes the three live-binding exports
|
|
143
|
+
* above, then re-runs the same config-integrity checks that used to run once at module load
|
|
144
|
+
* (back when the registry was a static object literal fully known at that point). */
|
|
145
|
+
export function __recomputeContentTypes() {
|
|
146
|
+
contentTypeDefs = {
|
|
147
|
+
...coreContentTypeDefs,
|
|
148
|
+
...(Object.fromEntries(entrySlugs.map((slug) => [slug, entryContentTypeDef(entries[slug])])) as Record<EntrySlug, ContentTypeDef>),
|
|
149
|
+
}
|
|
150
|
+
contentTypeKeys = Object.keys(contentTypeDefs) as ContentTypeKey[]
|
|
151
|
+
blankFieldValues = {
|
|
152
|
+
...coreBlankFieldValues(),
|
|
153
|
+
...(Object.fromEntries(entrySlugs.map((slug) => [
|
|
154
|
+
slug,
|
|
155
|
+
Object.fromEntries(entries[slug].fields
|
|
156
|
+
.filter((field) => !field.readOnly && !(workflowColumnKeys as readonly string[]).includes(field.key))
|
|
157
|
+
.map((field) => [field.key, blankValueForField(field)])),
|
|
158
|
+
])) as Record<EntrySlug, Record<string, unknown>>),
|
|
159
|
+
}
|
|
160
|
+
runConfigIntegrityChecks()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function runConfigIntegrityChecks() {
|
|
164
|
+
if (process.env.NODE_ENV === 'production') return
|
|
165
|
+
for (const slug of entrySlugs) {
|
|
166
|
+
const config = entries[slug]
|
|
167
|
+
const stray = config.fields.find((field) => (workflowColumnKeys as readonly string[]).includes(field.key))
|
|
168
|
+
if (stray) console.error(`[collections] ${slug}: "${stray.key}" is an engine-owned workflow column and must not be declared as a field.`)
|
|
169
|
+
if (config.workflow?.review && !config.workflow.audit) console.error(`[collections] ${slug}: workflow.review requires workflow.audit.`)
|
|
170
|
+
if (config.kind === 'single' && (config.workflow?.review || config.workflow?.trash || config.workflow?.scheduling))
|
|
171
|
+
console.error(`[collections] ${slug}: single types support workflow.publish/audit only in v1.`)
|
|
172
|
+
const scheduling = config.workflow?.scheduling
|
|
173
|
+
if (scheduling) {
|
|
174
|
+
const dateField = config.fields.find((field) => field.key === scheduling.dateField)
|
|
175
|
+
if (dateField?.type !== 'date') console.error(`[collections] ${slug}: workflow.scheduling.dateField "${scheduling.dateField}" must be a declared 'date' field.`)
|
|
176
|
+
if (scheduling.timeField) {
|
|
177
|
+
const timeField = config.fields.find((field) => field.key === scheduling.timeField)
|
|
178
|
+
if (timeField?.type !== 'time') console.error(`[collections] ${slug}: workflow.scheduling.timeField "${scheduling.timeField}" must be a declared 'time' field.`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const field of config.fields) {
|
|
182
|
+
if (field.type === 'text' && field.slugFrom && field.format !== 'slug')
|
|
183
|
+
console.error(`[collections] ${slug}.${field.key}: slugFrom is only honored with format: 'slug'.`)
|
|
184
|
+
if (field.type === 'text' && field.slugFrom && !config.fields.some((other) => other.key === field.slugFrom))
|
|
185
|
+
console.error(`[collections] ${slug}.${field.key}: slugFrom "${field.slugFrom}" is not a declared field.`)
|
|
186
|
+
if (field.type === 'text' && field.suggestionsFrom && !isCollectionSlug(field.suggestionsFrom.collection))
|
|
187
|
+
console.error(`[collections] ${slug}.${field.key}: suggestionsFrom targets an unknown collection.`)
|
|
188
|
+
if (field.type === 'text' && field.format === 'password' && (field.searchable || field.sortable))
|
|
189
|
+
console.error(`[collections] ${slug}.${field.key}: a password field must not be searchable/sortable.`)
|
|
190
|
+
if (field.type === 'relation' && field.hasMany && (field.filterable || field.searchable || field.sortable))
|
|
191
|
+
console.error(`[collections] ${slug}.${field.key}: a hasMany relation must not be filterable/searchable/sortable.`)
|
|
192
|
+
if (field.type === 'join' && (field.readOnly === false))
|
|
193
|
+
console.error(`[collections] ${slug}.${field.key}: join has no backing column — never declare readOnly: false on it.`)
|
|
194
|
+
if (field.type === 'join' && (field.filterable || field.searchable || field.sortable))
|
|
195
|
+
console.error(`[collections] ${slug}.${field.key}: join must not be filterable/searchable/sortable — no backing column to query against. (listColumn IS supported.)`)
|
|
196
|
+
if (field.type === 'join') {
|
|
197
|
+
const target = entries[field.collection]
|
|
198
|
+
const onField = target?.fields.find((other) => other.key === field.on)
|
|
199
|
+
if (!onField || onField.type !== 'relation' || onField.hasMany)
|
|
200
|
+
console.error(`[collections] ${slug}.${field.key}: "on" must name a declared non-hasMany 'relation' field on "${field.collection}".`)
|
|
201
|
+
}
|
|
202
|
+
if (field.localized && field.type !== 'text' && field.type !== 'textarea' && field.type !== 'richtext')
|
|
203
|
+
console.error(`[collections] ${slug}.${field.key}: localized is only honored on text/textarea/richtext.`)
|
|
204
|
+
if (field.type === 'array' || field.type === 'blocks' || field.type === 'group') {
|
|
205
|
+
const nested = field.type === 'group' || field.type === 'array' ? field.fields : field.blockTypes.flatMap((blockDef) => blockDef.fields)
|
|
206
|
+
for (const item of nested) if ('localized' in item && (item as { localized?: boolean }).localized)
|
|
207
|
+
console.error(`[collections] ${slug}.${field.key}: localized is not supported on array/block/group item fields.`)
|
|
208
|
+
}
|
|
209
|
+
if (field.type === 'blocks') {
|
|
210
|
+
const seen = new Set<string>()
|
|
211
|
+
for (const blockDef of field.blockTypes) {
|
|
212
|
+
if (seen.has(blockDef.type)) console.error(`[collections] ${slug}.${field.key}: duplicate block type "${blockDef.type}".`)
|
|
213
|
+
seen.add(blockDef.type)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { entries, entrySlugs } from './collections/registry'
|
|
2
|
+
import type { EntrySlug } from './collections/types'
|
|
3
|
+
|
|
4
|
+
// Any registered collection/single can be a dashboard widget's data source — no longer a
|
|
5
|
+
// hardcoded literal union of one host's specific slugs.
|
|
6
|
+
export type DashboardSource = EntrySlug
|
|
7
|
+
export type DashboardMetric = 'count' | 'sum' | 'avg' | 'true' | 'false'
|
|
8
|
+
export type DashboardVisual = 'number' | 'bar' | 'line' | 'donut' | 'table'
|
|
9
|
+
export type DashboardSize = 'small' | 'medium' | 'large'
|
|
10
|
+
export type DashboardWidget = {
|
|
11
|
+
id: string
|
|
12
|
+
title: string
|
|
13
|
+
source: DashboardSource
|
|
14
|
+
metric: DashboardMetric
|
|
15
|
+
field?: string
|
|
16
|
+
groupBy?: string
|
|
17
|
+
visual: DashboardVisual
|
|
18
|
+
limit: number
|
|
19
|
+
size: DashboardSize
|
|
20
|
+
}
|
|
21
|
+
export type DashboardConfig = { widgets: DashboardWidget[] }
|
|
22
|
+
|
|
23
|
+
// A genuinely empty starter — a host's specific collections (and which fields are worth
|
|
24
|
+
// charting) aren't something the package can guess at. Build your own default from the
|
|
25
|
+
// Dashboard Builder UI; this is only what a brand-new install sees before anyone has.
|
|
26
|
+
export const defaultDashboard: DashboardConfig = { widgets: [] }
|
|
27
|
+
|
|
28
|
+
/** Computed from whichever collections/singles are actually registered — not a hardcoded
|
|
29
|
+
* list. `fields` always offers the engine-owned workflow columns every entry has, plus
|
|
30
|
+
* whichever of that entry's own fields are `filterable` (a reasonable proxy for "meaningful
|
|
31
|
+
* to group/count by"). */
|
|
32
|
+
export function getDashboardSources(): Array<{ value: DashboardSource; label: string; fields: string[] }> {
|
|
33
|
+
return entrySlugs.map((slug) => {
|
|
34
|
+
const config = entries[slug]
|
|
35
|
+
const ownFields = config.fields.filter((field) => 'filterable' in field && field.filterable).map((field) => field.key)
|
|
36
|
+
const workflowFields = config.workflow ? ['published', 'reviewStatus', 'sortOrder'].filter((key) => key === 'published' ? config.workflow?.publish : key === 'reviewStatus' ? config.workflow?.review : config.workflow?.sortOrder) : []
|
|
37
|
+
return { value: slug, label: config.label, fields: [...ownFields, ...workflowFields] }
|
|
38
|
+
})
|
|
39
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
// kilo-cms doesn't own the database connection — the HOST app creates its own drizzle client
|
|
4
|
+
// (its own DATABASE_URL, its own merged schema) and hands it to `defineKiloConfig({ db, ... })`.
|
|
5
|
+
// Everything inside the package that needs to run a query imports `db` from here instead of
|
|
6
|
+
// creating/importing a connection directly, so the package never hardcodes one specific host's
|
|
7
|
+
// connection. The lazy Proxy means every existing `db.select()...`-style call site inside the
|
|
8
|
+
// package keeps working unchanged — only this file's import path is new, not the usage.
|
|
9
|
+
//
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the real type is the host's
|
|
11
|
+
// own drizzle instance, whatever schema it was created with; kilo-cms only needs the parts of
|
|
12
|
+
// the query builder API it actually calls, so a precise generic isn't worth chasing here.
|
|
13
|
+
type AnyDb = any
|
|
14
|
+
|
|
15
|
+
let _db: AnyDb | null = null
|
|
16
|
+
|
|
17
|
+
/** Called once by `defineKiloConfig()` — never call this directly from application code. */
|
|
18
|
+
export function __setDb(instance: AnyDb) {
|
|
19
|
+
_db = instance
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const db: AnyDb = new Proxy(
|
|
23
|
+
{},
|
|
24
|
+
{
|
|
25
|
+
get(_target, prop) {
|
|
26
|
+
if (!_db) throw new Error('kilo-cms: defineKiloConfig() must run before the database is used — import and call it from your app entry (e.g. instrumentation.ts or the root layout) before any request touches the CMS.')
|
|
27
|
+
return _db[prop]
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// Same lazy-injection pattern, for the better-auth instance the host creates via
|
|
33
|
+
// `createKiloAuth()` (see ./auth) — `createKiloAuth` calls `__setAuth` itself as its last step,
|
|
34
|
+
// so anything inside the package needing `auth` (permissions.ts's session lookups) just imports
|
|
35
|
+
// it from here rather than assuming there's one global instance it can create/import directly.
|
|
36
|
+
let _auth: AnyDb | null = null
|
|
37
|
+
|
|
38
|
+
export function __setAuth(instance: AnyDb) {
|
|
39
|
+
_auth = instance
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const auth: AnyDb = new Proxy(
|
|
43
|
+
{},
|
|
44
|
+
{
|
|
45
|
+
get(_target, prop) {
|
|
46
|
+
if (!_auth) throw new Error('kilo-cms: createKiloAuth() must run before auth is used.')
|
|
47
|
+
return _auth[prop]
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
)
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { eq } from 'drizzle-orm'
|
|
4
|
+
import nodemailer from 'nodemailer'
|
|
5
|
+
import { db } from './db'
|
|
6
|
+
import { emailSettings } from './schema'
|
|
7
|
+
import { decryptSecret } from './secrets'
|
|
8
|
+
|
|
9
|
+
const DOMAIN = 'cms-email-secrets'
|
|
10
|
+
|
|
11
|
+
export async function getEmailSettings() {
|
|
12
|
+
const [settings] = await db.select().from(emailSettings).where(eq(emailSettings.id, 'default')).limit(1)
|
|
13
|
+
return settings ?? null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isEmailConfigured(settings: { host: string | null; port: number | null; fromAddress: string | null } | null) {
|
|
17
|
+
return Boolean(settings?.host && settings.port && settings.fromAddress)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function sendEmail({ to, subject, html, text }: { to: string; subject: string; html?: string; text?: string }) {
|
|
21
|
+
const settings = await getEmailSettings()
|
|
22
|
+
if (!isEmailConfigured(settings)) throw new Error('Email isn\'t configured yet. Add SMTP settings first.')
|
|
23
|
+
const password = settings!.encryptedPassword ? decryptSecret(settings!.encryptedPassword, DOMAIN) : undefined
|
|
24
|
+
const transport = nodemailer.createTransport({
|
|
25
|
+
host: settings!.host!,
|
|
26
|
+
port: settings!.port!,
|
|
27
|
+
secure: settings!.secure,
|
|
28
|
+
auth: settings!.username ? { user: settings!.username, pass: password } : undefined,
|
|
29
|
+
})
|
|
30
|
+
await transport.sendMail({
|
|
31
|
+
from: settings!.fromName ? `"${settings!.fromName}" <${settings!.fromAddress}>` : settings!.fromAddress!,
|
|
32
|
+
to,
|
|
33
|
+
subject,
|
|
34
|
+
html,
|
|
35
|
+
text: text ?? (html ? undefined : subject),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { DOMAIN as EMAIL_SECRET_DOMAIN }
|