@setzkasten-cms/core 0.4.2

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/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @setzkasten-cms/core – Public API
3
+ // ---------------------------------------------------------------------------
4
+
5
+ // Schema
6
+ export { defineConfig, defineSection, defineCollection } from './schema/define-config'
7
+ export type {
8
+ SetzKastenConfig,
9
+ SectionDefinition,
10
+ ProductDefinition,
11
+ CollectionDefinition,
12
+ StorageConfig,
13
+ AuthConfig,
14
+ ThemeConfig,
15
+ IconsConfig,
16
+ } from './schema/define-config'
17
+
18
+ // Fields
19
+ export { f } from './fields/factories'
20
+ export type {
21
+ FieldType,
22
+ FieldPath,
23
+ FieldDefinition,
24
+ FieldRecord,
25
+ InferFieldValue,
26
+ InferSchemaValues,
27
+ AnyFieldDef,
28
+ TextFieldDef,
29
+ NumberFieldDef,
30
+ BooleanFieldDef,
31
+ SelectFieldDef,
32
+ IconFieldDef,
33
+ ImageFieldDef,
34
+ ImageValue,
35
+ ArrayFieldDef,
36
+ ObjectFieldDef,
37
+ ColorFieldDef,
38
+ OverrideFieldDef,
39
+ SelectOption,
40
+ } from './fields/field-definition'
41
+
42
+ // Validation
43
+ export { fieldToZod, schemaToZod } from './validation/schema-to-zod'
44
+
45
+ // Serialization
46
+ export { serializeEntry, deserializeEntry } from './serialization/serializer'
47
+ export type { FieldSerializer, SerializerRegistry } from './serialization/serializer'
48
+ export { jsonSerializerRegistry } from './serialization/json-serializer'
49
+
50
+ // Commands
51
+ export { createCommandHistory } from './commands/command'
52
+ export type { Command, CommandHistory } from './commands/command'
53
+
54
+ // Events
55
+ export { createEventBus } from './events/content-event-bus'
56
+ export type { ContentEvent, ContentEventType, ContentEventBus, Unsubscribe } from './events/content-event-bus'
57
+
58
+ // Errors
59
+ export {
60
+ ok,
61
+ err,
62
+ validationError,
63
+ conflictError,
64
+ rateLimitError,
65
+ authError,
66
+ networkError,
67
+ notFoundError,
68
+ serializationError,
69
+ } from './errors/errors'
70
+ export type {
71
+ Result,
72
+ SetzKastenError,
73
+ ValidationError,
74
+ ConflictError,
75
+ RateLimitError,
76
+ AuthError,
77
+ NetworkError,
78
+ NotFoundError,
79
+ SerializationError,
80
+ } from './errors/errors'
81
+
82
+ // Ports (interfaces only)
83
+ export type { ContentRepository, EntryData, Asset, CommitResult, TreeNode, EntryListItem } from './ports/content-repository'
84
+ export type { AuthProvider, AuthUser, AuthSession } from './ports/auth-provider'
85
+ export type { AssetStore, AssetMetadata } from './ports/asset-store'
@@ -0,0 +1,36 @@
1
+ import type { Result } from '../errors/errors'
2
+
3
+ export interface AssetMetadata {
4
+ readonly path: string
5
+ readonly size: number
6
+ readonly mimeType: string
7
+ readonly width?: number
8
+ readonly height?: number
9
+ }
10
+
11
+ /**
12
+ * Asset store port – abstracts image/file storage.
13
+ * Default implementation uses GitHub Contents API.
14
+ * Could be swapped for S3, Cloudinary, etc.
15
+ */
16
+ export interface AssetStore {
17
+ /** Upload an asset, preserving the original filename */
18
+ upload(
19
+ directory: string,
20
+ filename: string,
21
+ content: Uint8Array,
22
+ mimeType: string,
23
+ ): Promise<Result<AssetMetadata>>
24
+
25
+ /** Delete an asset */
26
+ delete(path: string): Promise<Result<void>>
27
+
28
+ /** List assets in a directory */
29
+ list(directory: string): Promise<Result<AssetMetadata[]>>
30
+
31
+ /** Get a public URL for an asset (for the deployed website) */
32
+ getUrl(path: string): string
33
+
34
+ /** Get a preview URL that works immediately (e.g. raw GitHub URL) */
35
+ getPreviewUrl?(path: string): string
36
+ }
@@ -0,0 +1,32 @@
1
+ import type { Result } from '../errors/errors'
2
+
3
+ export interface AuthUser {
4
+ readonly id: string
5
+ readonly email: string
6
+ readonly name?: string
7
+ readonly avatarUrl?: string
8
+ readonly provider: 'github' | 'google' | 'email'
9
+ }
10
+
11
+ export interface AuthSession {
12
+ readonly user: AuthUser
13
+ readonly expiresAt: number
14
+ }
15
+
16
+ /**
17
+ * Auth provider port – abstracts authentication.
18
+ * Determines WHO can access the CMS, separate from HOW content is committed.
19
+ */
20
+ export interface AuthProvider {
21
+ /** Get current session (null if not authenticated) */
22
+ getSession(): Promise<Result<AuthSession | null>>
23
+
24
+ /** Get the OAuth redirect URL for login */
25
+ getLoginUrl(provider: 'github' | 'google' | 'email'): string
26
+
27
+ /** Exchange auth callback for session */
28
+ handleCallback(code: string, provider: string): Promise<Result<AuthSession>>
29
+
30
+ /** End session */
31
+ logout(): Promise<void>
32
+ }
@@ -0,0 +1,59 @@
1
+ import type { Result } from '../errors/errors'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Content Repository Port – the core abstraction over storage
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface EntryData {
8
+ readonly content: Record<string, unknown>
9
+ readonly sha?: string // for conflict detection (GitHub file SHA)
10
+ }
11
+
12
+ export interface Asset {
13
+ readonly path: string
14
+ readonly content: Uint8Array
15
+ readonly mimeType: string
16
+ }
17
+
18
+ export interface CommitResult {
19
+ readonly sha: string
20
+ readonly message: string
21
+ readonly url?: string
22
+ }
23
+
24
+ export interface TreeNode {
25
+ readonly path: string
26
+ readonly type: 'file' | 'dir'
27
+ readonly sha?: string
28
+ }
29
+
30
+ export interface EntryListItem {
31
+ readonly slug: string
32
+ readonly name: string
33
+ }
34
+
35
+ /**
36
+ * Repository pattern: abstracts content storage.
37
+ * Implemented by github-adapter (and potentially local-filesystem, GitLab, etc.)
38
+ */
39
+ export interface ContentRepository {
40
+ /** List all entries in a collection/singleton directory */
41
+ listEntries(collection: string): Promise<Result<EntryListItem[]>>
42
+
43
+ /** Read a single entry's content */
44
+ getEntry(collection: string, slug: string): Promise<Result<EntryData>>
45
+
46
+ /** Save an entry with optional assets (images). Creates a single atomic commit. */
47
+ saveEntry(
48
+ collection: string,
49
+ slug: string,
50
+ data: EntryData,
51
+ assets?: Asset[],
52
+ ): Promise<Result<CommitResult>>
53
+
54
+ /** Delete an entry */
55
+ deleteEntry(collection: string, slug: string): Promise<Result<CommitResult>>
56
+
57
+ /** Get the full repository tree (for navigation) */
58
+ getTree(ref?: string): Promise<Result<TreeNode[]>>
59
+ }
@@ -0,0 +1,96 @@
1
+ import type { FieldRecord } from '../fields/field-definition'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Section definition
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface SectionDefinition<TFields extends FieldRecord = FieldRecord> {
8
+ readonly label: string
9
+ readonly icon?: string
10
+ readonly fields: TFields
11
+ }
12
+
13
+ export function defineSection<TFields extends FieldRecord>(options: {
14
+ label: string
15
+ icon?: string
16
+ fields: TFields
17
+ }): SectionDefinition<TFields> {
18
+ return Object.freeze({
19
+ label: options.label,
20
+ icon: options.icon,
21
+ fields: options.fields,
22
+ })
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Product definition
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface ProductDefinition {
30
+ readonly label: string
31
+ readonly sections: Record<string, SectionDefinition>
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Collection definition
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface CollectionDefinition<TFields extends FieldRecord = FieldRecord> {
39
+ readonly label: string
40
+ readonly path: string
41
+ readonly slugField: string
42
+ readonly fields: TFields
43
+ }
44
+
45
+ export function defineCollection<TFields extends FieldRecord>(options: {
46
+ label: string
47
+ path: string
48
+ slugField: string
49
+ fields: TFields
50
+ }): CollectionDefinition<TFields> {
51
+ return Object.freeze({
52
+ label: options.label,
53
+ path: options.path,
54
+ slugField: options.slugField,
55
+ fields: options.fields,
56
+ })
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Config definition
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface StorageConfig {
64
+ readonly kind: 'github' | 'local'
65
+ readonly repo?: string
66
+ readonly branch?: string
67
+ }
68
+
69
+ export interface AuthConfig {
70
+ readonly providers: readonly ('github' | 'google' | 'email')[]
71
+ readonly allowedEmails?: readonly string[]
72
+ }
73
+
74
+ export interface ThemeConfig {
75
+ readonly primaryColor: string
76
+ readonly logo?: string
77
+ readonly brandName?: string
78
+ }
79
+
80
+ export interface IconsConfig {
81
+ /** Icon sets to enable. Default: ['lucide'] */
82
+ readonly sets: readonly string[]
83
+ }
84
+
85
+ export interface SetzKastenConfig {
86
+ readonly storage: StorageConfig
87
+ readonly auth: AuthConfig
88
+ readonly theme?: ThemeConfig
89
+ readonly icons?: IconsConfig
90
+ readonly products: Record<string, ProductDefinition>
91
+ readonly collections?: Record<string, CollectionDefinition>
92
+ }
93
+
94
+ export function defineConfig(config: SetzKastenConfig): SetzKastenConfig {
95
+ return Object.freeze(config)
96
+ }
@@ -0,0 +1,87 @@
1
+ import type {
2
+ ArrayFieldDef,
3
+ FieldRecord,
4
+ ObjectFieldDef,
5
+ OverrideFieldDef,
6
+ } from '../fields/field-definition'
7
+ import type { FieldSerializer, SerializerRegistry } from './serializer'
8
+
9
+ /**
10
+ * JSON serializers – backward-compatible with Keystatic's JSON format.
11
+ *
12
+ * Most fields serialize as-is (text → string, number → number, etc.)
13
+ * Complex fields (array, object, override) recurse into their children.
14
+ */
15
+
16
+ const textSerializer: FieldSerializer<string, string> = {
17
+ serialize: (value) => value,
18
+ deserialize: (raw) => (typeof raw === 'string' ? raw : String(raw ?? '')),
19
+ }
20
+
21
+ const numberSerializer: FieldSerializer<number, number> = {
22
+ serialize: (value) => value,
23
+ deserialize: (raw) => (typeof raw === 'number' ? raw : Number(raw ?? 0)),
24
+ }
25
+
26
+ const booleanSerializer: FieldSerializer<boolean, boolean> = {
27
+ serialize: (value) => value,
28
+ deserialize: (raw) => Boolean(raw),
29
+ }
30
+
31
+ const selectSerializer: FieldSerializer<string, string> = {
32
+ serialize: (value) => value,
33
+ deserialize: (raw) => String(raw ?? ''),
34
+ }
35
+
36
+ const imageSerializer: FieldSerializer = {
37
+ serialize: (value) => {
38
+ if (typeof value === 'string') return value
39
+ if (value && typeof value === 'object' && 'path' in value) {
40
+ return (value as { path: string }).path
41
+ }
42
+ return ''
43
+ },
44
+ deserialize: (raw) => {
45
+ // Keystatic stores image paths as plain strings
46
+ if (typeof raw === 'string') return { path: raw, alt: '' }
47
+ if (raw && typeof raw === 'object' && 'path' in raw) return raw
48
+ return { path: '', alt: '' }
49
+ },
50
+ }
51
+
52
+ const colorSerializer: FieldSerializer<string, string> = {
53
+ serialize: (value) => value,
54
+ deserialize: (raw) => String(raw ?? '#000000'),
55
+ }
56
+
57
+ /**
58
+ * Default JSON serializer registry.
59
+ * Compatible with Keystatic's JSON output format.
60
+ */
61
+ export const jsonSerializerRegistry: SerializerRegistry = {
62
+ text: textSerializer,
63
+ number: numberSerializer,
64
+ boolean: booleanSerializer,
65
+ select: selectSerializer,
66
+ image: imageSerializer,
67
+ color: colorSerializer,
68
+ // array, object, override are handled recursively by serializeEntry/deserializeEntry
69
+ array: {
70
+ serialize: (value) => (Array.isArray(value) ? value : []),
71
+ deserialize: (raw) => (Array.isArray(raw) ? raw : []),
72
+ },
73
+ object: {
74
+ serialize: (value) => (value && typeof value === 'object' ? value : {}),
75
+ deserialize: (raw) => (raw && typeof raw === 'object' ? raw : {}),
76
+ },
77
+ override: {
78
+ serialize: (value) => {
79
+ if (value && typeof value === 'object') return value
80
+ return { active: false }
81
+ },
82
+ deserialize: (raw) => {
83
+ if (raw && typeof raw === 'object') return raw
84
+ return { active: false }
85
+ },
86
+ },
87
+ }
@@ -0,0 +1,65 @@
1
+ import type { FieldDefinition, FieldRecord } from '../fields/field-definition'
2
+
3
+ /**
4
+ * Strategy interface for field serialization.
5
+ * Each field type defines how it converts between editor values and stored format.
6
+ */
7
+ export interface FieldSerializer<TValue = unknown, TSerialized = unknown> {
8
+ serialize(value: TValue): TSerialized
9
+ deserialize(raw: TSerialized): TValue
10
+ }
11
+
12
+ /**
13
+ * Registry of serializers keyed by field type.
14
+ */
15
+ export type SerializerRegistry = Record<string, FieldSerializer>
16
+
17
+ /**
18
+ * Serialize an entire schema's values to a flat JSON object.
19
+ */
20
+ export function serializeEntry(
21
+ fields: FieldRecord,
22
+ values: Record<string, unknown>,
23
+ registry: SerializerRegistry,
24
+ ): Record<string, unknown> {
25
+ const result: Record<string, unknown> = {}
26
+
27
+ for (const [key, field] of Object.entries(fields)) {
28
+ const serializer = registry[field.type]
29
+ const value = values[key]
30
+
31
+ if (serializer && value !== undefined) {
32
+ result[key] = serializer.serialize(value)
33
+ } else if (value !== undefined) {
34
+ result[key] = value
35
+ }
36
+ }
37
+
38
+ return result
39
+ }
40
+
41
+ /**
42
+ * Deserialize a stored JSON object back to editor values.
43
+ */
44
+ export function deserializeEntry(
45
+ fields: FieldRecord,
46
+ raw: Record<string, unknown>,
47
+ registry: SerializerRegistry,
48
+ ): Record<string, unknown> {
49
+ const result: Record<string, unknown> = {}
50
+
51
+ for (const [key, field] of Object.entries(fields)) {
52
+ const serializer = registry[field.type]
53
+ const rawValue = raw[key]
54
+
55
+ if (serializer && rawValue !== undefined) {
56
+ result[key] = serializer.deserialize(rawValue)
57
+ } else if (rawValue !== undefined) {
58
+ result[key] = rawValue
59
+ } else if (field.defaultValue !== undefined) {
60
+ result[key] = field.defaultValue
61
+ }
62
+ }
63
+
64
+ return result
65
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @setzkasten-cms/core/testing – Test utilities for other packages
3
+ */
4
+
5
+ import { f } from '../fields/factories'
6
+ import { defineConfig, defineSection } from '../schema/define-config'
7
+
8
+ /**
9
+ * Creates a minimal test config for use in tests.
10
+ */
11
+ export function createTestConfig() {
12
+ return defineConfig({
13
+ storage: { kind: 'local' },
14
+ auth: { providers: ['email'] },
15
+ products: {
16
+ test: {
17
+ label: 'Test Product',
18
+ sections: {
19
+ hero: defineSection({
20
+ label: 'Hero',
21
+ fields: {
22
+ heading: f.text({ label: 'Heading', required: true }),
23
+ subtitle: f.text({ label: 'Subtitle', multiline: true }),
24
+ badge: f.text({ label: 'Badge' }),
25
+ },
26
+ }),
27
+ benefits: defineSection({
28
+ label: 'Benefits',
29
+ fields: {
30
+ items: f.array(
31
+ f.object(
32
+ {
33
+ title: f.text({ label: 'Title' }),
34
+ description: f.text({ label: 'Description', multiline: true }),
35
+ icon: f.select({
36
+ label: 'Icon',
37
+ options: [
38
+ { label: 'Check', value: 'check' },
39
+ { label: 'Star', value: 'star' },
40
+ ],
41
+ }),
42
+ },
43
+ { label: 'Benefit' },
44
+ ),
45
+ { label: 'Benefits', minItems: 1 },
46
+ ),
47
+ },
48
+ }),
49
+ },
50
+ },
51
+ },
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Creates sample content data matching the test config schema.
57
+ */
58
+ export function createTestContent() {
59
+ return {
60
+ hero: {
61
+ heading: 'Test Heading',
62
+ subtitle: 'Test Subtitle',
63
+ badge: 'New',
64
+ },
65
+ benefits: {
66
+ items: [
67
+ { title: 'Benefit 1', description: 'Description 1', icon: 'check' },
68
+ { title: 'Benefit 2', description: 'Description 2', icon: 'star' },
69
+ ],
70
+ },
71
+ }
72
+ }
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { f } from '../fields/factories'
3
+ import { fieldToZod, schemaToZod } from './schema-to-zod'
4
+
5
+ describe('Schema to Zod', () => {
6
+ describe('fieldToZod()', () => {
7
+ it('validates required text fields', () => {
8
+ const field = f.text({ label: 'Title', required: true })
9
+ const schema = fieldToZod(field)
10
+
11
+ expect(schema.safeParse('Hello').success).toBe(true)
12
+ expect(schema.safeParse('').success).toBe(false)
13
+ })
14
+
15
+ it('validates text maxLength', () => {
16
+ const field = f.text({ label: 'Short', maxLength: 5 })
17
+ const schema = fieldToZod(field)
18
+
19
+ expect(schema.safeParse('Hi').success).toBe(true)
20
+ expect(schema.safeParse('Too long text').success).toBe(false)
21
+ })
22
+
23
+ it('validates text pattern', () => {
24
+ const field = f.text({ label: 'Email', pattern: /^.+@.+\..+$/ })
25
+ const schema = fieldToZod(field)
26
+
27
+ expect(schema.safeParse('test@example.com').success).toBe(true)
28
+ expect(schema.safeParse('not-an-email').success).toBe(false)
29
+ })
30
+
31
+ it('validates number min/max', () => {
32
+ const field = f.number({ label: 'Rating', min: 1, max: 5 })
33
+ const schema = fieldToZod(field)
34
+
35
+ expect(schema.safeParse(3).success).toBe(true)
36
+ expect(schema.safeParse(0).success).toBe(false)
37
+ expect(schema.safeParse(6).success).toBe(false)
38
+ })
39
+
40
+ it('validates boolean', () => {
41
+ const field = f.boolean({ label: 'Active' })
42
+ const schema = fieldToZod(field)
43
+
44
+ expect(schema.safeParse(true).success).toBe(true)
45
+ expect(schema.safeParse(false).success).toBe(true)
46
+ expect(schema.safeParse('yes').success).toBe(false)
47
+ })
48
+
49
+ it('validates select options', () => {
50
+ const field = f.select({
51
+ label: 'Style',
52
+ options: [
53
+ { label: 'Primary', value: 'primary' },
54
+ { label: 'Secondary', value: 'secondary' },
55
+ ],
56
+ })
57
+ const schema = fieldToZod(field)
58
+
59
+ expect(schema.safeParse('primary').success).toBe(true)
60
+ expect(schema.safeParse('tertiary').success).toBe(false)
61
+ })
62
+
63
+ it('validates color hex format', () => {
64
+ const field = f.color({ label: 'Color' })
65
+ const schema = fieldToZod(field)
66
+
67
+ expect(schema.safeParse('#a2c617').success).toBe(true)
68
+ expect(schema.safeParse('#fff').success).toBe(false)
69
+ expect(schema.safeParse('red').success).toBe(false)
70
+ })
71
+
72
+ it('validates arrays with min/max items', () => {
73
+ const field = f.array(f.text({ label: 'Tag' }), {
74
+ label: 'Tags',
75
+ minItems: 1,
76
+ maxItems: 3,
77
+ })
78
+ const schema = fieldToZod(field)
79
+
80
+ expect(schema.safeParse(['a']).success).toBe(true)
81
+ expect(schema.safeParse(['a', 'b', 'c']).success).toBe(true)
82
+ expect(schema.safeParse([]).success).toBe(false)
83
+ expect(schema.safeParse(['a', 'b', 'c', 'd']).success).toBe(false)
84
+ })
85
+
86
+ it('validates nested objects', () => {
87
+ const field = f.object(
88
+ {
89
+ name: f.text({ label: 'Name', required: true }),
90
+ age: f.number({ label: 'Age', min: 0 }),
91
+ },
92
+ { label: 'Person' },
93
+ )
94
+ const schema = fieldToZod(field)
95
+
96
+ expect(schema.safeParse({ name: 'Alice', age: 30 }).success).toBe(true)
97
+ expect(schema.safeParse({ name: '', age: 30 }).success).toBe(false)
98
+ })
99
+
100
+ it('validates override fields', () => {
101
+ const field = f.override(
102
+ {
103
+ heading: f.text({ label: 'Heading' }),
104
+ },
105
+ { label: 'Override' },
106
+ )
107
+ const schema = fieldToZod(field)
108
+
109
+ expect(schema.safeParse({ active: true, heading: 'Test' }).success).toBe(true)
110
+ expect(schema.safeParse({ active: false }).success).toBe(true)
111
+ expect(schema.safeParse({}).success).toBe(false) // missing active
112
+ })
113
+ })
114
+
115
+ describe('schemaToZod()', () => {
116
+ it('converts a full schema to Zod', () => {
117
+ const fields = {
118
+ title: f.text({ label: 'Title', required: true }),
119
+ description: f.text({ label: 'Description', multiline: true }),
120
+ rating: f.number({ label: 'Rating', min: 1, max: 5 }),
121
+ published: f.boolean({ label: 'Published' }),
122
+ }
123
+
124
+ const schema = schemaToZod(fields)
125
+
126
+ const valid = { title: 'Hello', description: 'World', rating: 3, published: true }
127
+ expect(schema.safeParse(valid).success).toBe(true)
128
+
129
+ const invalid = { title: '', rating: 3, published: true }
130
+ expect(schema.safeParse(invalid).success).toBe(false)
131
+ })
132
+ })
133
+ })