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.
Files changed (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. 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
+ }
@@ -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 }