kilo-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
package/src/ai-writer.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|