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,84 @@
1
+ import 'server-only'
2
+
3
+ import { getAiProviderKey } from './ai-config'
4
+ import type { AiAction, AiProvider } from './ai-types'
5
+
6
+ export type AiWriteInput = { action: AiAction; prompt?: string; selection?: string; documentText?: string }
7
+ export type AiWriteResult = { state: 'complete'; text: string } | { state: 'pending'; taskId: string } | { state: 'error'; error: string }
8
+
9
+ const models = { deepseek: 'deepseek-v4-flash', openai: 'gpt-5', anthropic: 'claude-sonnet-4-6' } as const
10
+ const system = 'You are an expert CMS writing assistant. Return only the requested final prose as plain text. Do not use Markdown, headings made from symbols, preambles, explanations, or quotation marks around the result. Preserve the language of the source text unless the user asks otherwise.'
11
+
12
+ function writingPrompt({ action, prompt, selection, documentText }: AiWriteInput) {
13
+ const source = selection?.trim() || documentText?.trim() || ''
14
+ if (action === 'prompt') return selection?.trim()
15
+ ? `${system}\n\nSelected text:\n${selection}\n\nUser request: ${prompt}\n\nReturn only the replacement text for the selection.`
16
+ : `${system}\n\nDocument context:\n${documentText || '(empty document)'}\n\nUser request: ${prompt}\n\nWrite content that satisfies the request.`
17
+ if (action === 'continue') return `${system}\n\nContinue this document naturally from where it ends. Return only the new continuation, without repeating any existing text:\n\n${documentText || '(empty document)'}`
18
+ if (action === 'improve') return `${system}\n\nImprove and rewrite this selected text for clarity, flow, and impact. Keep its meaning and approximate length unless a rewrite clearly benefits from changing it. Return only the replacement text:\n\n${source}`
19
+ return `${system}\n\nSummarize this selected text concisely while retaining its essential points. Return only the summary:\n\n${source}`
20
+ }
21
+
22
+ async function responseJson(response: Response) {
23
+ const body = await response.text()
24
+ let data: unknown = null
25
+ try { data = body ? JSON.parse(body) : null } catch {}
26
+ if (!response.ok) {
27
+ const message = typeof data === 'object' && data && 'error' in data ? JSON.stringify((data as { error: unknown }).error) : body
28
+ throw new Error(message || `Provider request failed (${response.status}).`)
29
+ }
30
+ return data as Record<string, unknown>
31
+ }
32
+
33
+ function readOpenAiText(data: Record<string, unknown>) {
34
+ if (typeof data.output_text === 'string' && data.output_text.trim()) return data.output_text.trim()
35
+ const output = Array.isArray(data.output) ? data.output : []
36
+ const text = output.flatMap((item) => typeof item === 'object' && item && 'content' in item && Array.isArray(item.content) ? item.content : []).map((part) => typeof part === 'object' && part && 'text' in part && typeof part.text === 'string' ? part.text : '').join('').trim()
37
+ if (!text) throw new Error('OpenAI returned no text.')
38
+ return text
39
+ }
40
+
41
+ async function directWrite(provider: Exclude<AiProvider, 'manus'>, prompt: string) {
42
+ const key = await getAiProviderKey(provider)
43
+ if (provider === 'deepseek') {
44
+ const response = await fetch('https://api.deepseek.com/chat/completions', { method: 'POST', signal: AbortSignal.timeout(60_000), headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: models.deepseek, messages: [{ role: 'system', content: system }, { role: 'user', content: prompt }], temperature: 0.7, max_tokens: 1800 }) })
45
+ const data = await responseJson(response)
46
+ const text = Array.isArray(data.choices) ? (data.choices[0] as { message?: { content?: string } } | undefined)?.message?.content : undefined
47
+ if (!text?.trim()) throw new Error('DeepSeek returned no text.')
48
+ return text.trim()
49
+ }
50
+ if (provider === 'openai') {
51
+ const response = await fetch('https://api.openai.com/v1/responses', { method: 'POST', signal: AbortSignal.timeout(60_000), headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: models.openai, input: prompt }) })
52
+ return readOpenAiText(await responseJson(response))
53
+ }
54
+ const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', signal: AbortSignal.timeout(60_000), headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }, body: JSON.stringify({ model: models.anthropic, max_tokens: 1800, system, messages: [{ role: 'user', content: prompt }] }) })
55
+ const data = await responseJson(response)
56
+ const text = Array.isArray(data.content) ? data.content.filter((part): part is { type: string; text: string } => typeof part === 'object' && part !== null && 'type' in part && 'text' in part && (part as { type: unknown }).type === 'text' && typeof (part as { text: unknown }).text === 'string').map((part) => part.text).join('').trim() : ''
57
+ if (!text) throw new Error('Anthropic returned no text.')
58
+ return text
59
+ }
60
+
61
+ export async function startAiWrite(provider: AiProvider, input: AiWriteInput): Promise<AiWriteResult> {
62
+ const prompt = writingPrompt(input)
63
+ if (provider !== 'manus') return { state: 'complete', text: await directWrite(provider, prompt) }
64
+ const key = await getAiProviderKey('manus')
65
+ const response = await fetch('https://api.manus.ai/v2/task.create', { method: 'POST', signal: AbortSignal.timeout(30_000), headers: { 'x-manus-api-key': key, 'Content-Type': 'application/json' }, body: JSON.stringify({ message: { content: prompt }, interactive_mode: false, hide_in_task_list: true, share_visibility: 'private', agent_profile: 'lite', structured_output_schema: { type: 'object', properties: { content: { type: 'string' } }, required: ['content'], additionalProperties: false } }) })
66
+ const data = await responseJson(response)
67
+ if (!data.ok || typeof data.task_id !== 'string') throw new Error('Manus could not start the writing task.')
68
+ return { state: 'pending', taskId: data.task_id }
69
+ }
70
+
71
+ export async function pollManusWrite(taskId: string): Promise<AiWriteResult> {
72
+ const key = await getAiProviderKey('manus')
73
+ const response = await fetch(`https://api.manus.ai/v2/task.listMessages?task_id=${encodeURIComponent(taskId)}&order=desc&limit=50`, { headers: { 'x-manus-api-key': key }, signal: AbortSignal.timeout(30_000) })
74
+ const data = await responseJson(response)
75
+ const messages = Array.isArray(data.messages) ? data.messages as Array<Record<string, unknown>> : []
76
+ const structured = messages.find((message) => message.type === 'structured_output_result')?.structured_output_result as { success?: boolean; value?: { content?: string }; error?: string | null } | undefined
77
+ if (structured) return structured.success && structured.value?.content?.trim() ? { state: 'complete', text: structured.value.content.trim() } : { state: 'error', error: structured.error || 'Manus could not produce a usable result.' }
78
+ const error = messages.find((message) => message.type === 'error_message')?.error_message as { content?: string } | undefined
79
+ if (error?.content) return { state: 'error', error: error.content }
80
+ const status = messages.find((message) => message.type === 'status_update')?.status_update as { agent_status?: string; status_detail?: { waiting_description?: string } } | undefined
81
+ if (status?.agent_status === 'waiting') return { state: 'error', error: status.status_detail?.waiting_description || 'Manus needs additional input. Try a more specific request.' }
82
+ if (status?.agent_status === 'error') return { state: 'error', error: 'Manus task failed.' }
83
+ return { state: 'pending', taskId }
84
+ }
@@ -0,0 +1,25 @@
1
+ import 'server-only'
2
+
3
+ import { randomBytes, createHash } from 'node:crypto'
4
+ import { eq } from 'drizzle-orm'
5
+ import { db } from './db'
6
+ import { apiTokens } from './schema'
7
+
8
+ export function hashToken(raw: string) {
9
+ return createHash('sha256').update(raw).digest('hex')
10
+ }
11
+
12
+ export function generateToken() {
13
+ const raw = `sk_${randomBytes(32).toString('base64url')}`
14
+ return { raw, hash: hashToken(raw), prefix: raw.slice(0, 10) }
15
+ }
16
+
17
+ export async function requireApiToken(request: Request) {
18
+ const header = request.headers.get('authorization') ?? ''
19
+ const match = /^Bearer\s+(.+)$/i.exec(header)
20
+ if (!match) return null
21
+ const [token] = await db.select().from(apiTokens).where(eq(apiTokens.tokenHash, hashToken(match[1]))).limit(1)
22
+ if (!token) return null
23
+ db.update(apiTokens).set({ lastUsedAt: new Date() }).where(eq(apiTokens.id, token.id)).catch(() => {})
24
+ return token
25
+ }
@@ -0,0 +1,5 @@
1
+ 'use client'
2
+
3
+ import { createAuthClient } from 'better-auth/react'
4
+
5
+ export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_SITE_URL })
package/src/auth.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { betterAuth } from 'better-auth/minimal'
2
+ import { drizzleAdapter } from '@better-auth/drizzle-adapter'
3
+ import { schema } from './schema'
4
+ import { __setAuth } from './db'
5
+
6
+ export type KiloAuthConfig = {
7
+ /** The host's own drizzle instance — same one passed to `defineKiloConfig({ db })`. */
8
+ db: unknown
9
+ secret: string
10
+ baseURL: string
11
+ trustedOrigins?: string[]
12
+ }
13
+
14
+ /** Config-factory, same shape as calling `betterAuth({...})` directly — the host calls this
15
+ * once (see apps/site/src/lib/auth.ts for the reference host wiring) instead of this package
16
+ * hardcoding one app's secrets/URL. Only kilo-cms's OWN auth tables (user/session/account/
17
+ * verification) are passed to the adapter; the host's own content tables are irrelevant to
18
+ * better-auth and never need to be threaded through here. */
19
+ export function createKiloAuth(config: KiloAuthConfig) {
20
+ const instance = betterAuth({
21
+ database: drizzleAdapter(config.db as never, { provider: 'pg', schema }),
22
+ secret: config.secret,
23
+ baseURL: config.baseURL,
24
+ trustedOrigins: config.trustedOrigins ?? [config.baseURL],
25
+ emailAndPassword: { enabled: true, disableSignUp: false, requireEmailVerification: false },
26
+ disabledPaths: ['/sign-up/email'],
27
+ user: { additionalFields: { role: { type: 'string', defaultValue: 'editor', input: false } } },
28
+ })
29
+ // permissions.ts (and anything else inside the package doing session lookups) reads `auth`
30
+ // from ./db's lazy proxy rather than assuming a global instance — this is what feeds it.
31
+ __setAuth(instance)
32
+ return instance
33
+ }
@@ -0,0 +1,12 @@
1
+ export const weekdayLabels = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
2
+ const pad2 = (value: number) => String(value).padStart(2, '0')
3
+ export const toISODate = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
4
+ export function parseISODate(value: string | null) { if (!value) return null; const [year, month, day] = value.split('-').map(Number); return new Date(year, month - 1, day) }
5
+ export function monthGrid(viewMonth: Date) {
6
+ const year = viewMonth.getFullYear(); const month = viewMonth.getMonth()
7
+ const startOffset = new Date(year, month, 1).getDay()
8
+ const daysInMonth = new Date(year, month + 1, 0).getDate()
9
+ const cells: Array<Date | null> = Array.from({ length: startOffset }, () => null)
10
+ for (let day = 1; day <= daysInMonth; day++) cells.push(new Date(year, month, day))
11
+ return cells
12
+ }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ // Plain ESM — deliberately not TypeScript, so this runs on any Node (22.6+) without a loader,
3
+ // build step, or dependency on how the host project executes TS. Run from the HOST app's own
4
+ // directory (e.g. `cd apps/site && npx kilo-cms <command>`), same way `drizzle-kit` is run.
5
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
6
+ import { join, dirname } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { spawnSync } from 'node:child_process'
9
+
10
+ const cliDir = dirname(fileURLToPath(import.meta.url))
11
+ const packageRoot = join(cliDir, '..', '..') // packages/kilo-cms
12
+ const cwd = process.cwd() // the host app's directory
13
+
14
+ const [, , command, ...args] = process.argv
15
+
16
+ function fail(message) {
17
+ console.error(`kilo-cms: ${message}`)
18
+ process.exit(1)
19
+ }
20
+
21
+ function slugToLabel(slug) {
22
+ const words = slug.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').trim()
23
+ return words.charAt(0).toUpperCase() + words.slice(1)
24
+ }
25
+
26
+ function singularize(label) {
27
+ return label.endsWith('ies') ? `${label.slice(0, -3)}y` : label.endsWith('s') ? label.slice(0, -1) : label
28
+ }
29
+
30
+ // --- add-collection ---------------------------------------------------------------------
31
+
32
+ function addCollection(slug) {
33
+ if (!slug) fail('usage: kilo-cms add-collection <slug>')
34
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(slug)) fail(`"${slug}" should be camelCase, starting with a lowercase letter (e.g. "projectCategories").`)
35
+
36
+ const collectionsDir = join(cwd, 'src', 'collections')
37
+ const targetDir = join(collectionsDir, slug)
38
+ if (existsSync(targetDir)) fail(`src/collections/${slug} already exists.`)
39
+ if (!existsSync(collectionsDir)) fail(`src/collections/ not found — run this from a host app that has kilo-cms installed (e.g. apps/site).`)
40
+
41
+ mkdirSync(targetDir, { recursive: true })
42
+ const label = slugToLabel(slug)
43
+ const labelSingular = singularize(label)
44
+ const varName = `${slug}Fields`
45
+
46
+ const fieldsFile = `import { defineCollectionFields } from 'kilo-cms/collections'
47
+
48
+ export const ${varName} = defineCollectionFields({
49
+ kind: 'collection',
50
+ slug: '${slug}',
51
+ label: '${label}',
52
+ labelSingular: '${labelSingular}',
53
+ titleField: 'title',
54
+ defaultSort: { field: 'sortOrder', dir: 'asc' },
55
+ touchUpdatedAt: true,
56
+ groups: [
57
+ { id: 'overview', label: 'Overview' },
58
+ { id: 'record', label: 'Record', description: 'Managed by the CMS' },
59
+ ],
60
+ fields: [
61
+ { key: 'title', type: 'text', label: 'Title', group: 'overview', required: true, maxLength: 120, listColumn: true, listPrimary: true, searchable: true, sortable: true },
62
+ { key: 'slug', type: 'text', label: 'Slug', group: 'overview', width: 'half', required: true, format: 'slug', slugFrom: 'title', maxLength: 120, listColumn: true, searchable: true, sortable: true },
63
+ { key: 'sortOrder', type: 'number', label: 'Sort order', group: 'overview', integer: true, readOnly: true },
64
+ { key: 'createdAt', type: 'datetime', label: 'Created', group: 'record', readOnly: true, nullable: true, width: 'half', sortable: true },
65
+ { key: 'updatedAt', type: 'datetime', label: 'Last updated', group: 'record', readOnly: true, nullable: true, width: 'half', sortable: true, listColumn: true },
66
+ ],
67
+ })
68
+ `
69
+
70
+ const tableFile = `import 'server-only'
71
+ import { pgTable, text, integer } from 'drizzle-orm/pg-core'
72
+ import { timestamps } from 'kilo-cms/schema'
73
+ import { defineCollectionTable } from 'kilo-cms/collections/table'
74
+
75
+ export const ${slug} = defineCollectionTable('${slug}', pgTable('cms_${slug.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()}', {
76
+ id: text('id').primaryKey(),
77
+ slug: text('slug').notNull().unique(),
78
+ title: text('title').notNull(),
79
+ sortOrder: integer('sort_order').notNull().default(0),
80
+ ...timestamps,
81
+ }))
82
+ `
83
+
84
+ writeFileSync(join(targetDir, 'fields.ts'), fieldsFile)
85
+ writeFileSync(join(targetDir, 'table.ts'), tableFile)
86
+
87
+ console.log(`Created src/collections/${slug}/fields.ts and table.ts.\n`)
88
+ console.log('Add these 3 lines to kilo.config.ts to register it:\n')
89
+ console.log(` import { ${varName} } from './src/collections/${slug}/fields'`)
90
+ console.log(` import './src/collections/${slug}/table'`)
91
+ console.log(` // then add ${varName} to the \`collections: [...]\` array\n`)
92
+ console.log('Adjust the scaffolded fields to match what this collection actually needs, then run `npx kilo-cms sync`.')
93
+ }
94
+
95
+ // --- sync ---------------------------------------------------------------------------------
96
+
97
+ function sync() {
98
+ const collectionsDir = join(cwd, 'src', 'collections')
99
+ if (!existsSync(collectionsDir)) fail('src/collections/ not found — run this from a host app that has kilo-cms installed.')
100
+
101
+ const slugs = readdirSync(collectionsDir).filter((name) => statSync(join(collectionsDir, name)).isDirectory()).sort()
102
+ if (!slugs.length) fail('No collections found under src/collections/.')
103
+
104
+ const unionBody = slugs.map((slug) => ` | '${slug}'`).join('\n')
105
+ const out = `// Generated by \`npx kilo-cms sync\` from src/collections/*/ — do not hand-edit.
106
+ // A real, checkable union for THIS app's collections, layered on top of the engine's own
107
+ // generic \`CollectionSlug = string\` (kilo-cms/collections) — import this type in your own
108
+ // app code where you want real exhaustiveness/autocomplete instead of a bare string.
109
+ export type CollectionSlug =
110
+ ${unionBody}
111
+ `
112
+ const outDir = join(cwd, '.kilo')
113
+ mkdirSync(outDir, { recursive: true })
114
+ writeFileSync(join(outDir, 'types.gen.ts'), out)
115
+ console.log(`Wrote .kilo/types.gen.ts with ${slugs.length} collection${slugs.length === 1 ? '' : 's'}: ${slugs.join(', ')}`)
116
+ }
117
+
118
+ // --- migrate --------------------------------------------------------------------------------
119
+
120
+ function migrate() {
121
+ console.log('Applying kilo-cms\'s own package-owned migrations (auth, RBAC, media, settings, dev-tools, admin-ui, locales)...')
122
+ const result = spawnSync('npx', ['drizzle-kit', 'migrate', '--config', join(packageRoot, 'drizzle.kilo.config.ts')], {
123
+ stdio: 'inherit',
124
+ cwd,
125
+ env: process.env,
126
+ })
127
+ if (result.status !== 0) process.exit(result.status ?? 1)
128
+ console.log('\nRun your own `db:generate`/`db:migrate` next for this app\'s own content tables.')
129
+ }
130
+
131
+ // --- dispatch -------------------------------------------------------------------------------
132
+
133
+ switch (command) {
134
+ case 'add-collection':
135
+ addCollection(args[0])
136
+ break
137
+ case 'sync':
138
+ sync()
139
+ break
140
+ case 'migrate':
141
+ migrate()
142
+ break
143
+ default:
144
+ console.log(`kilo-cms — usage:
145
+ kilo-cms add-collection <slug> scaffold a new collection's fields.ts + table.ts
146
+ kilo-cms sync regenerate .kilo/types.gen.ts from src/collections/*
147
+ kilo-cms migrate apply kilo-cms's own package-owned migrations`)
148
+ process.exit(command ? 1 : 0)
149
+ }
@@ -0,0 +1,55 @@
1
+ export const cmsColorKeys = ['ink', 'paper', 'acid', 'acidHover', 'acidInk', 'muted', 'line', 'danger', 'dangerSoft', 'canvas', 'surface', 'surfaceSoft'] as const
2
+ export const cmsSizeKeys = ['radius', 'radiusSm', 'space1', 'space2', 'space3', 'space4', 'space5'] as const
3
+
4
+ export type CmsColorKey = typeof cmsColorKeys[number]
5
+ export type CmsSizeKey = typeof cmsSizeKeys[number]
6
+ export type CmsAppearanceTokens = Record<CmsColorKey, string> & Record<CmsSizeKey, number>
7
+ export type CmsAppearance = { preset: string; light: Partial<CmsAppearanceTokens>; dark: Partial<CmsAppearanceTokens> }
8
+
9
+ type CmsAppearancePreset = { id: string; name: string; description: string; light: Partial<CmsAppearanceTokens>; dark: Partial<CmsAppearanceTokens> }
10
+
11
+ const sizes = { radius: 8, radiusSm: 6, space1: 4, space2: 8, space3: 12, space4: 16, space5: 24 }
12
+ const acidLight = { ink: '#171717', paper: '#f3f1ed', acid: '#d9ff55', acidHover: '#ecff9b', acidInk: '#171717', muted: '#696863', line: '#c9c6be', danger: '#c43b2d', dangerSoft: '#ff9c8f', canvas: '#e5e1d9', surface: '#ffffff', surfaceSoft: '#f8f7f4' }
13
+ const acidDark = { ink: '#f3f1ed', paper: '#171717', acid: '#d9ff55', acidHover: '#ecff9b', acidInk: '#171717', muted: '#a6a39b', line: '#54524d', danger: '#ff6b57', dangerSoft: '#ffb0a5', canvas: '#242424', surface: '#2d2d2d', surfaceSoft: '#353535' }
14
+
15
+ export const cmsAppearancePresets: CmsAppearancePreset[] = [
16
+ { id: 'acid', name: 'Acid', description: 'The current Kilo CMS signature.', light: acidLight, dark: acidDark },
17
+ { id: 'pink', name: 'Pink', description: 'Warm, expressive pink.', light: { ...acidLight, acid: '#ff75c6', acidHover: '#ffaadf' }, dark: { ...acidDark, acid: '#ff75c6', acidHover: '#ffaadf' } },
18
+ { id: 'sky', name: 'Sky blue', description: 'Crisp and airy blue.', light: { ...acidLight, acid: '#73d9ff', acidHover: '#a9e9ff' }, dark: { ...acidDark, acid: '#73d9ff', acidHover: '#a9e9ff' } },
19
+ { id: 'navy', name: 'Navy', description: 'Calm navy with blue accent.', light: { ...acidLight, ink: '#13213b', acid: '#74b8ff', acidHover: '#a9d4ff' }, dark: { ...acidDark, paper: '#101827', canvas: '#172033', surface: '#202c42', surfaceSoft: '#2a3850', acid: '#74b8ff', acidHover: '#a9d4ff' } },
20
+ { id: 'rose', name: 'Rose', description: 'Soft rose editorial palette.', light: { ...acidLight, acid: '#ff9eae', acidHover: '#ffc2cc' }, dark: { ...acidDark, acid: '#ff9eae', acidHover: '#ffc2cc' } },
21
+ { id: 'amber', name: 'Amber', description: 'Golden amber contrast.', light: { ...acidLight, acid: '#ffc44d', acidHover: '#ffda87' }, dark: { ...acidDark, acid: '#ffc44d', acidHover: '#ffda87' } },
22
+ { id: 'mono', name: 'Black & white', description: 'No accent colour, maximum restraint.', light: { ...acidLight, acid: '#171717', acidHover: '#3d3d3d', acidInk: '#ffffff' }, dark: { ...acidDark, acid: '#f3f1ed', acidHover: '#ffffff', acidInk: '#171717' } },
23
+ { id: 'maroon-gold', name: 'Maroon gold', description: 'Dark maroon, gold, and ivory.', light: { ...acidLight, ink: '#341017', paper: '#fff9eb', acid: '#d6a72c', acidHover: '#efc95c', canvas: '#eee1cd', surface: '#fffdf7' }, dark: { ...acidDark, paper: '#1d0a0d', canvas: '#291012', surface: '#351417', surfaceSoft: '#451d20', acid: '#d6a72c', acidHover: '#efc95c', acidInk: '#24170a', muted: '#d2bda5', line: '#664245' } },
24
+ ]
25
+
26
+ export const defaultCmsAppearance: CmsAppearance = { preset: 'acid', light: {}, dark: {} }
27
+
28
+ function normalizeOverrides(value: unknown) {
29
+ if (!value || typeof value !== 'object') return {}
30
+ const source = value as Record<string, unknown>
31
+ const colors = Object.fromEntries(cmsColorKeys.flatMap((key) => typeof source[key] === 'string' && /^#[0-9a-fA-F]{6}$/.test(source[key]) ? [[key, source[key]]] : []))
32
+ const numbers = Object.fromEntries(cmsSizeKeys.flatMap((key) => typeof source[key] === 'number' && Number.isInteger(source[key]) && source[key] >= 0 && source[key] <= 64 ? [[key, source[key]]] : []))
33
+ return { ...colors, ...numbers }
34
+ }
35
+
36
+ export function normalizeCmsAppearance(value: unknown): CmsAppearance {
37
+ if (!value || typeof value !== 'object') return defaultCmsAppearance
38
+ const candidate = value as Partial<CmsAppearance>
39
+ const preset = cmsAppearancePresets.some((item) => item.id === candidate.preset) ? candidate.preset! : defaultCmsAppearance.preset
40
+ return { preset, light: normalizeOverrides(candidate.light), dark: normalizeOverrides(candidate.dark) }
41
+ }
42
+
43
+ export function presetForAppearance(appearance: CmsAppearance) { return cmsAppearancePresets.find((item) => item.id === appearance.preset) ?? cmsAppearancePresets[0] }
44
+
45
+ export function resolvedAppearanceTokens(appearance: CmsAppearance, mode: 'light' | 'dark'): CmsAppearanceTokens {
46
+ const preset = presetForAppearance(appearance)
47
+ return { ...sizes, ...(mode === 'light' ? preset.light : preset.dark), ...(mode === 'light' ? appearance.light : appearance.dark) } as CmsAppearanceTokens
48
+ }
49
+
50
+ const cssNames: Record<keyof CmsAppearanceTokens, string> = { ink: '--ink', paper: '--paper', acid: '--acid', acidHover: '--acid-hover', acidInk: '--acid-ink', muted: '--muted', line: '--line', danger: '--danger', dangerSoft: '--danger-soft', canvas: '--canvas', surface: '--surface', surfaceSoft: '--surface-soft', radius: '--radius', radiusSm: '--radius-sm', space1: '--space-1', space2: '--space-2', space3: '--space-3', space4: '--space-4', space5: '--space-5' }
51
+
52
+ export function appearanceCssVariables(appearance: CmsAppearance, mode: 'light' | 'dark') {
53
+ const tokens = resolvedAppearanceTokens(appearance, mode)
54
+ return Object.fromEntries(Object.entries(tokens).map(([key, value]) => [cssNames[key as keyof CmsAppearanceTokens], typeof value === 'number' ? `${value}px` : value]))
55
+ }
@@ -0,0 +1,69 @@
1
+ import { getEntry } from './registry'
2
+ import type { EntryConfig } from './types'
3
+
4
+ // Named distinctly from FieldConfig's own `FieldWidth` (./types) to avoid an ambiguous-export
5
+ // collision in the client-safe barrel (kilo-cms/collections re-exports both files) — the two
6
+ // are coincidentally the same shape but serve different purposes (this one is a saved layout
7
+ // preference; the other governs a field's actual rendered width).
8
+ export type ContentViewFieldWidth = 'full' | 'half' | 'third'
9
+ export type ContentViewGroup = { id: string; label: string; description?: string }
10
+ export type ContentViewField = { key: string; label: string; description?: string; groupId: string | null; width: ContentViewFieldWidth }
11
+ export type ContentViewSettings = { groups: ContentViewGroup[]; fields: ContentViewField[] }
12
+
13
+ const validWidths: ContentViewFieldWidth[] = ['full', 'half', 'third']
14
+ const cleanId = (value: string) => value.toLowerCase().trim().replace(/[^a-z0-9-]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 40)
15
+
16
+ export function isContentViewType(value: string): boolean {
17
+ return getEntry(value) !== null
18
+ }
19
+
20
+ /** For a caller that already HAS the resolved `EntryConfig` (e.g. a client component that
21
+ * received it as a prop from its server-rendered page, rather than looking it up itself) —
22
+ * see `defaultContentView` below for why that distinction matters. Pure, no registry lookup. */
23
+ export function defaultContentViewFor(config: EntryConfig): ContentViewSettings {
24
+ return {
25
+ groups: config.groups.map((group) => ({ ...group })),
26
+ // Hidden fields never render a form control (see FieldConfig.hidden) — leaving them out here
27
+ // means the View settings drag-and-drop UI never offers a slot that ContentEditorForm would
28
+ // then silently drop.
29
+ fields: config.fields.filter((field) => !field.hidden).map((field) => ({ key: field.key, label: field.label, description: field.hint, groupId: field.group, width: field.width ?? 'full' })),
30
+ }
31
+ }
32
+
33
+ /** Looks `type` up in the registry itself — safe to call server-side (or anywhere the registry
34
+ * is known to be populated), but NEVER from a client component: the registry is only ever
35
+ * populated by `defineKiloConfig()`, which is server-only, so a client-side call always sees
36
+ * an empty registry and silently falls back to the generic default below. Client code that
37
+ * already has the resolved config (typically passed down as a prop from its server page)
38
+ * should call `defaultContentViewFor(config)` instead. */
39
+ export function defaultContentView(type: string): ContentViewSettings {
40
+ const config = getEntry(type)
41
+ if (config) return defaultContentViewFor(config)
42
+ return { groups: [{ id: 'overview', label: 'Overview' }], fields: [] }
43
+ }
44
+
45
+ export function normalizeContentView(type: string, input: unknown): ContentViewSettings {
46
+ const fallback = defaultContentView(type)
47
+ if (!input || typeof input !== 'object') return fallback
48
+ const raw = input as Partial<ContentViewSettings>
49
+ const groups = Array.isArray(raw.groups) ? raw.groups.map((group) => ({ id: typeof group?.id === 'string' ? cleanId(group.id) : '', label: typeof group?.label === 'string' ? group.label.trim().slice(0, 60) : '', description: typeof group?.description === 'string' ? group.description.trim().slice(0, 180) : undefined })).filter((group) => group.id && group.label).slice(0, 12) : []
50
+ const safeGroups = groups.length ? groups : fallback.groups
51
+ const groupIds = new Set(safeGroups.map((group) => group.id))
52
+ const rawFields = Array.isArray(raw.fields) ? raw.fields : []
53
+ const byKey = new Map(rawFields.filter((field): field is ContentViewField => Boolean(field && typeof field.key === 'string')).map((field) => [field.key, field]))
54
+ const fields = fallback.fields.map((fallbackField) => {
55
+ const field = byKey.get(fallbackField.key)
56
+ return {
57
+ key: fallbackField.key,
58
+ label: typeof field?.label === 'string' && field.label.trim() ? field.label.trim().slice(0, 80) : fallbackField.label,
59
+ description: typeof field?.description === 'string' ? field.description.trim().slice(0, 180) : undefined,
60
+ groupId: field?.groupId === null ? null : typeof field?.groupId === 'string' && groupIds.has(field.groupId) ? field.groupId : fallbackField.groupId && groupIds.has(fallbackField.groupId) ? fallbackField.groupId : safeGroups[0].id,
61
+ width: field && validWidths.includes(field.width) ? field.width : fallbackField.width,
62
+ }
63
+ })
64
+ const fieldOrder = rawFields.map((field) => typeof field?.key === 'string' ? field.key : '').filter((key) => fields.some((field) => field.key === key))
65
+ return { groups: safeGroups, fields: [...fieldOrder.map((key) => fields.find((field) => field.key === key)!), ...fields.filter((field) => !fieldOrder.includes(field.key))] }
66
+ }
67
+
68
+ export function contentViewField(settings: ContentViewSettings, key: string) { return settings.fields.find((field) => field.key === key) }
69
+ export function contentViewLabels(settings: ContentViewSettings) { return Object.fromEntries(settings.fields.map((field) => [field.key, field.label])) }
@@ -0,0 +1,17 @@
1
+ // Client-safe. MUST NOT import 'drizzle-orm', './query', '../db', or 'server-only' — a host's
2
+ // `fields.ts` files (which call `defineCollectionFields`) are imported from client editor
3
+ // components (e.g. CollectionRecordEditor) to know what to render.
4
+ import type { CollectionConfig, SingleConfig } from './types'
5
+
6
+ /** Identity helper — mostly for type inference/DX (so a host's `fields.ts` gets full
7
+ * `CollectionConfig` autocomplete/checking without spelling out the type by hand) and a
8
+ * future home for config-shape validation. Registration itself happens later, when the
9
+ * config object this returns is listed in `defineKiloConfig({ collections: [...] })` — this
10
+ * function has no side effect, so it's safe to call from client-reachable code. */
11
+ export function defineCollectionFields(config: CollectionConfig): CollectionConfig {
12
+ return config
13
+ }
14
+
15
+ export function defineSingleFields(config: SingleConfig): SingleConfig {
16
+ return config
17
+ }
@@ -0,0 +1,92 @@
1
+ import 'server-only'
2
+ import { and, asc, desc, eq, ilike, isNotNull, isNull, ne, or, type SQL } from 'drizzle-orm'
3
+ import { z } from 'zod'
4
+ import type { ListQuery } from '../listQuery'
5
+ import { collectionColumns } from './query'
6
+ import { workflowListFilters, workflowSortKeys } from './types'
7
+ import type { EntryConfig } from './types'
8
+
9
+ const filterRuleSchema = z.object({
10
+ field: z.string().max(60),
11
+ operator: z.enum(['is', 'isNot', 'isEmpty', 'isNotEmpty']),
12
+ value: z.string().max(200).optional().default(''),
13
+ })
14
+
15
+ export function buildWhere(config: EntryConfig, query: ListQuery): SQL | undefined {
16
+ const columns = collectionColumns(config.slug)
17
+ const conditions: SQL[] = []
18
+
19
+ if (config.workflow?.trash && columns.deletedAt) {
20
+ conditions.push(query.trashed ? isNotNull(columns.deletedAt) : isNull(columns.deletedAt))
21
+ }
22
+
23
+ if (query.q) {
24
+ const clauses = config.fields
25
+ .filter((field) => field.searchable && (field.type === 'text' || field.type === 'textarea') && columns[field.key])
26
+ .map((field) => ilike(columns[field.key], `%${query.q}%`))
27
+ if (clauses.length) conditions.push(clauses.length === 1 ? clauses[0] : or(...clauses)!)
28
+ }
29
+
30
+ let rules: z.infer<typeof filterRuleSchema>[] = []
31
+ try {
32
+ const parsed = filterRuleSchema.array().safeParse(JSON.parse(query.filtersRaw))
33
+ if (parsed.success) rules = parsed.data
34
+ } catch {
35
+ // ignore malformed filters, same as the hand-written routes
36
+ }
37
+
38
+ for (const rule of rules) {
39
+ const workflowFilter = config.workflow
40
+ ? workflowListFilters.find((item) => item.key === rule.field && Boolean(config.workflow?.[item.requires]))
41
+ : undefined
42
+ if (workflowFilter && columns[workflowFilter.key]) {
43
+ const column = columns[workflowFilter.key]
44
+ if (workflowFilter.type === 'boolean') {
45
+ const scheduledAt = config.workflow?.scheduling ? columns.scheduledAt : undefined
46
+ if (scheduledAt && rule.value === 'scheduled') {
47
+ // Matches api/admin/blog/route.ts: isScheduled / isDraftOnly.
48
+ if (rule.operator === 'is') conditions.push(and(eq(column, false), isNotNull(scheduledAt))!)
49
+ else if (rule.operator === 'isNot') conditions.push(and(eq(column, false), isNull(scheduledAt))!)
50
+ continue
51
+ }
52
+ const wanted = rule.value === 'true'
53
+ if (rule.operator === 'is') conditions.push(scheduledAt && !wanted ? and(eq(column, false), isNull(scheduledAt))! : eq(column, wanted))
54
+ else if (rule.operator === 'isNot') conditions.push(eq(column, !wanted))
55
+ } else {
56
+ if (rule.operator === 'is') conditions.push(eq(column, rule.value))
57
+ else if (rule.operator === 'isNot') conditions.push(ne(column, rule.value))
58
+ }
59
+ continue
60
+ }
61
+
62
+ const field = config.fields.find((item) => item.key === rule.field && item.filterable)
63
+ const column = field && columns[field.key]
64
+ if (!field || !column) continue
65
+ if (field.type === 'boolean') {
66
+ const wanted = rule.value === 'true'
67
+ if (rule.operator === 'is') conditions.push(eq(column, wanted))
68
+ else if (rule.operator === 'isNot') conditions.push(eq(column, !wanted))
69
+ continue
70
+ }
71
+ if (rule.operator === 'is') conditions.push(eq(column, rule.value))
72
+ else if (rule.operator === 'isNot') conditions.push(ne(column, rule.value))
73
+ else if (rule.operator === 'isEmpty') conditions.push(field.nullable ? isNull(column) : eq(column, ''))
74
+ else if (rule.operator === 'isNotEmpty') conditions.push(field.nullable ? isNotNull(column) : ne(column, ''))
75
+ }
76
+
77
+ return conditions.length ? and(...conditions) : undefined
78
+ }
79
+
80
+ export function buildOrderBy(config: EntryConfig, query: ListQuery): SQL[] {
81
+ const columns = collectionColumns(config.slug)
82
+ const requested = config.fields.find((field) => field.key === query.sortBy && field.sortable)
83
+ const workflowSort = !requested && config.workflow && (workflowSortKeys as readonly string[]).includes(query.sortBy) && columns[query.sortBy]
84
+ ? query.sortBy
85
+ : null
86
+ const defaultKey = config.kind === 'collection' ? config.defaultSort.field : 'id'
87
+ const defaultDir = config.kind === 'collection' ? config.defaultSort.dir : 'asc'
88
+ const key = requested?.key ?? workflowSort ?? defaultKey
89
+ const column = columns[key] ?? columns[defaultKey] ?? columns.id
90
+ const dir = requested || workflowSort ? query.sortDir : defaultDir
91
+ return [dir === 'desc' ? desc(column) : asc(column)]
92
+ }
@@ -0,0 +1,11 @@
1
+ // Client-safe barrel. Every file re-exported here has NO 'server-only'/drizzle-orm import —
2
+ // this is the one path admin editor client components use to know what to render. Server-only
3
+ // engine pieces (validation, workflow, filters, join, single, query, table) are NOT here — see
4
+ // 'kilo-cms/collections/server'.
5
+ export * from './types'
6
+ export * from './define'
7
+ export * from './registry'
8
+ export * from './values'
9
+ export * from './content-view'
10
+ export * from './locale'
11
+ export * from './schedule'
@@ -0,0 +1,64 @@
1
+ import 'server-only'
2
+ import { eq, inArray } from 'drizzle-orm'
3
+ import { collectionColumn, selectRows, type Row } from './query'
4
+ import { getEntry } from './registry'
5
+ import type { EntryConfig, FieldConfig } from './types'
6
+
7
+ type JoinField = Extract<FieldConfig, { type: 'join' }>
8
+
9
+ function titleFieldFor(field: JoinField): string {
10
+ const target = getEntry(field.collection)
11
+ return target && target.kind === 'collection' ? target.titleField : 'id'
12
+ }
13
+
14
+ /** `join` fields have no backing column — resolved at GET time by querying `field.collection`
15
+ * for rows whose `field.on` (a `relation` field there) points back at this record's id.
16
+ * Single-row version: used by the two GET-by-id routes. */
17
+ export async function resolveJoinFields(config: EntryConfig, row: Row): Promise<Row> {
18
+ const joinFields = config.fields.filter((field): field is JoinField => field.type === 'join')
19
+ if (!joinFields.length) return row
20
+ const next = { ...row }
21
+ const id = String(row.id)
22
+ for (const field of joinFields) {
23
+ const column = collectionColumn(field.collection, field.on)
24
+ if (!column) { next[field.key] = []; continue }
25
+ const matches = await selectRows(field.collection, { where: eq(column, id) })
26
+ const titleField = titleFieldFor(field)
27
+ next[field.key] = matches.map((match) => ({ id: String(match.id), label: match[titleField] }))
28
+ }
29
+ return next
30
+ }
31
+
32
+ /** Batched version for the list route: ONE query per `listColumn: true` join field covering
33
+ * every row on the current page (`inArray(on, ids)`), grouped by which parent row each match
34
+ * belongs to — not one query per row. Join fields without `listColumn: true` are left alone
35
+ * (the list route never needs their value). */
36
+ export async function resolveJoinFieldsForList(config: EntryConfig, rows: Row[]): Promise<Row[]> {
37
+ const joinFields = config.fields.filter((field): field is JoinField => field.type === 'join' && Boolean(field.listColumn))
38
+ if (!joinFields.length || !rows.length) return rows
39
+ const ids = rows.map((row) => String(row.id))
40
+
41
+ const byField = new Map<string, Map<string, Array<{ id: string; label: unknown }>>>()
42
+ for (const field of joinFields) {
43
+ const column = collectionColumn(field.collection, field.on)
44
+ const grouped = new Map<string, Array<{ id: string; label: unknown }>>()
45
+ if (column) {
46
+ const matches = await selectRows(field.collection, { where: inArray(column, ids) })
47
+ const titleField = titleFieldFor(field)
48
+ for (const match of matches) {
49
+ const parentId = String(match[field.on])
50
+ const list = grouped.get(parentId)
51
+ const entry = { id: String(match.id), label: match[titleField] }
52
+ if (list) list.push(entry)
53
+ else grouped.set(parentId, [entry])
54
+ }
55
+ }
56
+ byField.set(field.key, grouped)
57
+ }
58
+
59
+ return rows.map((row) => {
60
+ const next = { ...row }
61
+ for (const field of joinFields) next[field.key] = byField.get(field.key)?.get(String(row.id)) ?? []
62
+ return next
63
+ })
64
+ }