kilo-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The live, admin-editable locale list lives in the `cms_locales` table — see
|
|
2
|
+
// `src/lib/locale-settings.ts` (server, DB-backed, request-cached) and `CmsLocalesProvider`
|
|
3
|
+
// (client, populated once per admin page load from the same table). This module is just the
|
|
4
|
+
// SHAPE of that config plus the fallback used wherever no live config was threaded through
|
|
5
|
+
// (mainly internal recursive calls that don't need it, since only the one top-level `localized`
|
|
6
|
+
// check in `blankValueForField`/`fieldSchema`/`formatFieldValue` ever reads it).
|
|
7
|
+
export type LocaleConfig = { codes: string[]; default: string; labels: Record<string, string> }
|
|
8
|
+
|
|
9
|
+
// Set once by `defineKiloConfig({ locales })` — a live ES-module binding, so every importer of
|
|
10
|
+
// `fallbackLocaleConfig` sees the host's real value once config runs. Starts as a single-locale
|
|
11
|
+
// no-op rather than a hardcoded language pair, since the package can't assume any host's
|
|
12
|
+
// locales — a host that never calls `locales` in its config just gets one default locale.
|
|
13
|
+
export let fallbackLocaleConfig: LocaleConfig = {
|
|
14
|
+
codes: ['en'],
|
|
15
|
+
default: 'en',
|
|
16
|
+
labels: { en: 'English' },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Called by `defineKiloConfig()` — not meant to be called directly from app code. */
|
|
20
|
+
export function __setLocaleConfig(config: LocaleConfig) {
|
|
21
|
+
fallbackLocaleConfig = config
|
|
22
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- drizzle's builder types cannot
|
|
3
|
+
express a table chosen at runtime; every call here is re-typed at the function boundary. */
|
|
4
|
+
import { count as sqlCount, eq, getTableColumns, inArray, isNotNull, isNull, max, type SQL } from 'drizzle-orm'
|
|
5
|
+
import type { PgColumn, PgTable } from 'drizzle-orm/pg-core'
|
|
6
|
+
import { db } from '../db'
|
|
7
|
+
import { relationFields } from './registry'
|
|
8
|
+
import type { EntryConfig, EntrySlug } from './types'
|
|
9
|
+
|
|
10
|
+
export type Row = Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
// slug -> drizzle table, populated by `defineCollectionTable()` calls in the host's own
|
|
13
|
+
// `table.ts` files (see kilo-cms/collections) — never a hardcoded import of specific tables.
|
|
14
|
+
const tables: Record<string, PgTable> = {}
|
|
15
|
+
|
|
16
|
+
/** Called by `defineCollectionTable()` — not meant to be called directly from app code. */
|
|
17
|
+
export function __registerTable(slug: string, table: PgTable) {
|
|
18
|
+
tables[slug] = table
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function collectionTable(slug: EntrySlug): PgTable { return tables[slug] }
|
|
22
|
+
|
|
23
|
+
export function collectionColumns(slug: EntrySlug): Record<string, PgColumn> {
|
|
24
|
+
return getTableColumns(tables[slug]) as Record<string, PgColumn>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function collectionColumn(slug: EntrySlug, key: string): PgColumn | null {
|
|
28
|
+
return collectionColumns(slug)[key] ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function idColumn(slug: EntrySlug): PgColumn {
|
|
32
|
+
const column = collectionColumn(slug, 'id')
|
|
33
|
+
if (!column) throw new Error(`Entry ${slug} has no id column`)
|
|
34
|
+
return column
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function selectRows(slug: EntrySlug, options: { where?: SQL; orderBy?: SQL[]; limit?: number; offset?: number } = {}): Promise<Row[]> {
|
|
38
|
+
let query: any = db.select().from(tables[slug] as any)
|
|
39
|
+
if (options.where) query = query.where(options.where)
|
|
40
|
+
if (options.orderBy?.length) query = query.orderBy(...options.orderBy)
|
|
41
|
+
if (typeof options.limit === 'number') query = query.limit(options.limit)
|
|
42
|
+
if (typeof options.offset === 'number') query = query.offset(options.offset)
|
|
43
|
+
return (await query) as Row[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function countRows(slug: EntrySlug, where?: SQL): Promise<number> {
|
|
47
|
+
let query: any = db.select({ value: sqlCount() }).from(tables[slug] as any)
|
|
48
|
+
if (where) query = query.where(where)
|
|
49
|
+
const [row] = (await query) as Array<{ value: number }>
|
|
50
|
+
return Number(row?.value ?? 0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function findRow(slug: EntrySlug, id: string): Promise<Row | null> {
|
|
54
|
+
const rows = await selectRows(slug, { where: eq(idColumn(slug), id), limit: 1 })
|
|
55
|
+
return rows[0] ?? null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function insertRow(slug: EntrySlug, values: Row): Promise<Row> {
|
|
59
|
+
const rows = (await (db.insert(tables[slug] as any) as any).values(values).returning()) as Row[]
|
|
60
|
+
return rows[0]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function updateRow(slug: EntrySlug, id: string, values: Row): Promise<Row | null> {
|
|
64
|
+
const rows = (await (db.update(tables[slug] as any) as any).set(values).where(eq(idColumn(slug), id)).returning()) as Row[]
|
|
65
|
+
return rows[0] ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function deleteRow(slug: EntrySlug, id: string): Promise<boolean> {
|
|
69
|
+
const rows = (await (db.delete(tables[slug] as any) as any).where(eq(idColumn(slug), id)).returning()) as Row[]
|
|
70
|
+
return rows.length > 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function deleteRows(slug: EntrySlug, ids: string[]): Promise<number> {
|
|
74
|
+
if (!ids.length) return 0
|
|
75
|
+
const rows = (await (db.delete(tables[slug] as any) as any).where(inArray(idColumn(slug), ids)).returning()) as Row[]
|
|
76
|
+
return rows.length
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** id -> title, for rendering relation cells and select options without N+1 queries. */
|
|
80
|
+
export async function labelsForIds(slug: EntrySlug, ids: string[], labelKey: string): Promise<Record<string, string>> {
|
|
81
|
+
if (!ids.length) return {}
|
|
82
|
+
const label = collectionColumn(slug, labelKey) ?? idColumn(slug)
|
|
83
|
+
const rows = (await (db.select({ id: idColumn(slug), label }).from(tables[slug] as any) as any)
|
|
84
|
+
.where(inArray(idColumn(slug), ids))) as Array<{ id: string; label: unknown }>
|
|
85
|
+
return Object.fromEntries(rows.map((row) => [row.id, String(row.label ?? row.id)]))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Returns the subset of `ids` that exist — used for FK pre-validation. */
|
|
89
|
+
export async function existingIds(slug: EntrySlug, ids: string[]): Promise<Set<string>> {
|
|
90
|
+
if (!ids.length) return new Set()
|
|
91
|
+
const rows = (await (db.select({ id: idColumn(slug) }).from(tables[slug] as any) as any)
|
|
92
|
+
.where(inArray(idColumn(slug), ids))) as Array<{ id: string }>
|
|
93
|
+
return new Set(rows.map((row) => row.id))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** FK pre-validation for every `relation` field on `data` (single or `hasMany`) — returns the
|
|
97
|
+
* first broken reference's error message, or null if everything referenced still exists. Shared
|
|
98
|
+
* by the collection create/update routes and the single-type update route. */
|
|
99
|
+
export async function findInvalidRelation(config: EntryConfig, data: Row): Promise<string | null> {
|
|
100
|
+
for (const field of relationFields(config)) {
|
|
101
|
+
const value = data[field.key]
|
|
102
|
+
const ids = field.hasMany
|
|
103
|
+
? (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item !== '') : [])
|
|
104
|
+
: (typeof value === 'string' && value ? [value] : [])
|
|
105
|
+
if (!ids.length) continue
|
|
106
|
+
const exists = await existingIds(field.relationTo, ids)
|
|
107
|
+
if (ids.some((id) => !exists.has(id))) return `${field.label}: that record no longer exists.`
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isForeignKeyViolation(error: unknown): boolean { return pgCode(error) === '23503' }
|
|
113
|
+
export function isUniqueViolation(error: unknown): boolean { return pgCode(error) === '23505' }
|
|
114
|
+
function pgCode(error: unknown): string | null {
|
|
115
|
+
return typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string'
|
|
116
|
+
? (error as { code: string }).code
|
|
117
|
+
: null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function updateWhere(slug: EntrySlug, where: SQL, values: Row): Promise<Row[]> {
|
|
121
|
+
return (await (db.update(tables[slug] as any) as any).set(values).where(where).returning()) as Row[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function updateRows(slug: EntrySlug, ids: string[], values: Row): Promise<number> {
|
|
125
|
+
if (!ids.length) return 0
|
|
126
|
+
return (await updateWhere(slug, inArray(idColumn(slug), ids), values)).length
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function findRowBy(slug: EntrySlug, key: string, value: string): Promise<Row | null> {
|
|
130
|
+
const column = collectionColumn(slug, key)
|
|
131
|
+
if (!column) throw new Error(`Entry ${slug} has no ${key} column`)
|
|
132
|
+
const rows = await selectRows(slug, { where: eq(column, value), limit: 1 })
|
|
133
|
+
return rows[0] ?? null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function upsertRowBy(slug: EntrySlug, key: string, values: Row, set: Row): Promise<Row> {
|
|
137
|
+
const column = collectionColumn(slug, key)
|
|
138
|
+
if (!column) throw new Error(`Entry ${slug} has no ${key} column`)
|
|
139
|
+
const rows = (await (db.insert(tables[slug] as any) as any).values(values).onConflictDoUpdate({ target: column, set }).returning()) as Row[]
|
|
140
|
+
return rows[0]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function nextSortOrder(slug: EntrySlug): Promise<number> {
|
|
144
|
+
const column = collectionColumn(slug, 'sortOrder')
|
|
145
|
+
if (!column) return 0
|
|
146
|
+
const rows = (await (db.select({ value: max(column) }).from(tables[slug] as any) as any)) as Array<{ value: number | null }>
|
|
147
|
+
return Number(rows[0]?.value ?? -1) + 1
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function applyOrder(slug: EntrySlug, ids: string[]): Promise<void> {
|
|
151
|
+
const column = collectionColumn(slug, 'sortOrder')
|
|
152
|
+
if (!column) throw new Error(`Entry ${slug} has no sortOrder column`)
|
|
153
|
+
await db.transaction(async (tx: any) => {
|
|
154
|
+
for (const [index, id] of ids.entries()) {
|
|
155
|
+
await (tx.update(tables[slug] as any) as any).set({ sortOrder: index }).where(eq(idColumn(slug), id))
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { isNotNull, isNull }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Runtime registry: a host app registers its own collections/singles by calling
|
|
2
|
+
// `defineKiloConfig({ collections: [...], singles: [...] })` once, at app startup (see
|
|
3
|
+
// kilo-cms/config). Nothing here hand-lists a fixed set of slugs — that's the whole point:
|
|
4
|
+
// this file used to require 4 manually-touched places to add one collection; now a host adds
|
|
5
|
+
// one `fields.ts` + `table.ts` pair and lists it in their own `kilo.config.ts`.
|
|
6
|
+
//
|
|
7
|
+
// The exported `let`s below (`collectionSlugs`, `entries`, etc.) are live ES-module bindings —
|
|
8
|
+
// every importer sees the up-to-date value automatically once `__registerCollections`/
|
|
9
|
+
// `__registerSingles` run, with no change needed at any call site that already did
|
|
10
|
+
// `import { collectionSlugs } from 'kilo-cms/collections'`.
|
|
11
|
+
import { workflowColumnKeys } from './types'
|
|
12
|
+
import type { CollectionConfig, CollectionSlug, EntryConfig, EntrySlug, FieldConfig, SingleConfig, SingleSlug } from './types'
|
|
13
|
+
|
|
14
|
+
const collections: Record<string, CollectionConfig> = {}
|
|
15
|
+
const singles: Record<string, SingleConfig> = {}
|
|
16
|
+
|
|
17
|
+
export let collectionSlugs: CollectionSlug[] = []
|
|
18
|
+
export let singleSlugs: SingleSlug[] = []
|
|
19
|
+
export let entries: Record<EntrySlug, EntryConfig> = {}
|
|
20
|
+
export let entrySlugs: EntrySlug[] = []
|
|
21
|
+
export let workflowSlugs: EntrySlug[] = []
|
|
22
|
+
export let reviewableSlugs: EntrySlug[] = []
|
|
23
|
+
export let trashableSlugs: EntrySlug[] = []
|
|
24
|
+
export let schedulingSlugs: EntrySlug[] = []
|
|
25
|
+
|
|
26
|
+
function recompute() {
|
|
27
|
+
collectionSlugs = Object.keys(collections)
|
|
28
|
+
singleSlugs = Object.keys(singles)
|
|
29
|
+
entries = { ...collections, ...singles }
|
|
30
|
+
entrySlugs = Object.keys(entries)
|
|
31
|
+
workflowSlugs = entrySlugs.filter((slug) => Boolean(entries[slug].workflow))
|
|
32
|
+
reviewableSlugs = entrySlugs.filter((slug) => entries[slug].workflow?.review)
|
|
33
|
+
trashableSlugs = entrySlugs.filter((slug) => entries[slug].workflow?.trash)
|
|
34
|
+
schedulingSlugs = entrySlugs.filter((slug) => Boolean(entries[slug].workflow?.scheduling))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Called once by `defineKiloConfig()` — not meant to be called directly from app code. */
|
|
38
|
+
export function __registerCollections(configs: CollectionConfig[]) {
|
|
39
|
+
for (const config of configs) {
|
|
40
|
+
// Catches the one mistake a hand-authored config can still make now that there's no
|
|
41
|
+
// `Record<CollectionSlug, ...>` object-literal shape forcing key === config.slug: a
|
|
42
|
+
// duplicate or mistyped slug across two `defineCollectionFields()` calls.
|
|
43
|
+
if (process.env.NODE_ENV !== 'production' && collections[config.slug]) {
|
|
44
|
+
console.error(`[kilo-cms] duplicate collection slug "${config.slug}" registered more than once.`)
|
|
45
|
+
}
|
|
46
|
+
collections[config.slug] = config
|
|
47
|
+
}
|
|
48
|
+
recompute()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Called once by `defineKiloConfig()` — not meant to be called directly from app code. */
|
|
52
|
+
export function __registerSingles(configs: SingleConfig[]) {
|
|
53
|
+
for (const config of configs) singles[config.slug] = config
|
|
54
|
+
recompute()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isCollectionSlug(value: string): value is CollectionSlug {
|
|
58
|
+
return Object.prototype.hasOwnProperty.call(collections, value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getCollection(slug: string): CollectionConfig | null {
|
|
62
|
+
return isCollectionSlug(slug) ? collections[slug] : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function collectionField(config: CollectionConfig, key: string): FieldConfig | undefined {
|
|
66
|
+
return config.fields.find((field) => field.key === key)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Fields a client may submit (everything except readOnly). */
|
|
70
|
+
export function writableFields(config: EntryConfig): FieldConfig[] {
|
|
71
|
+
return config.fields.filter((field) => !field.readOnly && !(workflowColumnKeys as readonly string[]).includes(field.key))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function relationFields(config: EntryConfig) {
|
|
75
|
+
return config.fields.filter((field): field is Extract<FieldConfig, { type: 'relation' }> => field.type === 'relation')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isSingleSlug(value: string): value is SingleSlug {
|
|
79
|
+
return Object.prototype.hasOwnProperty.call(singles, value)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getSingle(slug: string): SingleConfig | null {
|
|
83
|
+
return isSingleSlug(slug) ? singles[slug] : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getEntry(slug: string): EntryConfig | null {
|
|
87
|
+
return Object.prototype.hasOwnProperty.call(entries, slug) ? entries[slug] : null
|
|
88
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Pure. MUST NOT import 'drizzle-orm', '../schema', '../db', or 'server-only' —
|
|
2
|
+
// reachable from client editors.
|
|
3
|
+
|
|
4
|
+
/** Combines a 'YYYY-MM-DD' date and an optional 'HH:MM[:SS]' clock into a UTC ISO string,
|
|
5
|
+
* interpreting both in the browser's local timezone. Lifted verbatim from
|
|
6
|
+
* BlogEditor.combineScheduledAt() so scheduled times land on the same instant as today. */
|
|
7
|
+
export function combineScheduledAt(date: string | null, time: string | null): string | null {
|
|
8
|
+
if (!date) return null
|
|
9
|
+
const clock = time && /^\d{2}:\d{2}/.test(time) ? (time.length === 5 ? `${time}:00` : time) : '00:00:00'
|
|
10
|
+
const local = new Date(`${date}T${clock}`)
|
|
11
|
+
return Number.isNaN(local.getTime()) ? null : local.toISOString()
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Server-only barrel — every file here has 'server-only' and/or a drizzle-orm import. NEVER
|
|
2
|
+
// import this from a client component; use 'kilo-cms/collections' (the client-safe barrel)
|
|
3
|
+
// for anything an editor UI needs to know about a field/collection shape.
|
|
4
|
+
import 'server-only'
|
|
5
|
+
|
|
6
|
+
export * from './validation'
|
|
7
|
+
export * from './workflow'
|
|
8
|
+
export * from './filters'
|
|
9
|
+
export * from './join'
|
|
10
|
+
export * from './single'
|
|
11
|
+
export * from './query'
|
|
12
|
+
export * from './table'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { findRowBy, upsertRowBy, type Row } from './query'
|
|
4
|
+
import { emptyRecord } from './values'
|
|
5
|
+
import type { SingleConfig } from './types'
|
|
6
|
+
|
|
7
|
+
export async function readSingle(config: SingleConfig): Promise<Row> {
|
|
8
|
+
const row = await findRowBy(config.slug, config.fixedKey.column, config.fixedKey.value)
|
|
9
|
+
if (row) return row
|
|
10
|
+
return {
|
|
11
|
+
...emptyRecord(config),
|
|
12
|
+
id: config.fixedKey.column === 'id' ? config.fixedKey.value : '',
|
|
13
|
+
[config.fixedKey.column]: config.fixedKey.value,
|
|
14
|
+
...(config.workflow?.publish ? { published: false, publishedAt: null } : {}),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writeSingle(config: SingleConfig, data: Row, extra: Row): Promise<Row> {
|
|
19
|
+
const set = { ...data, ...extra, ...(config.touchUpdatedAt ? { updatedAt: new Date() } : {}) }
|
|
20
|
+
const values = {
|
|
21
|
+
id: config.fixedKey.column === 'id' ? config.fixedKey.value : crypto.randomUUID(),
|
|
22
|
+
[config.fixedKey.column]: config.fixedKey.value,
|
|
23
|
+
...set,
|
|
24
|
+
}
|
|
25
|
+
return upsertRowBy(config.slug, config.fixedKey.column, values, set)
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import type { PgTable } from 'drizzle-orm/pg-core'
|
|
3
|
+
import { __registerTable } from './query'
|
|
4
|
+
|
|
5
|
+
/** Registers a host's drizzle table against a collection/single slug so the generic engine
|
|
6
|
+
* (query.ts) can run CRUD against it — this is the runtime replacement for query.ts's old
|
|
7
|
+
* hardcoded `tables` map. Call it once per collection, in that collection's own `table.ts`
|
|
8
|
+
* (server-only — never import this from a `fields.ts` file, which must stay client-safe).
|
|
9
|
+
* Returns the table unchanged so it can still be exported/used normally elsewhere (e.g. a
|
|
10
|
+
* host's own `/api/v1/*` route doing its own direct query). */
|
|
11
|
+
export function defineCollectionTable<T extends PgTable>(slug: string, table: T): T {
|
|
12
|
+
__registerTable(slug, table)
|
|
13
|
+
return table
|
|
14
|
+
}
|