@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/LICENSE +37 -0
- package/dist/chunk-IL2PWN4R.js +142 -0
- package/dist/define-config-bJ65Zaui.d.ts +209 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +314 -0
- package/dist/testing.d.ts +28 -0
- package/dist/testing.js +69 -0
- package/package.json +32 -0
- package/src/commands/command.test.ts +85 -0
- package/src/commands/command.ts +65 -0
- package/src/errors/errors.ts +112 -0
- package/src/events/content-event-bus.test.ts +59 -0
- package/src/events/content-event-bus.ts +55 -0
- package/src/fields/factories.test.ts +168 -0
- package/src/fields/factories.ts +166 -0
- package/src/fields/field-definition.ts +215 -0
- package/src/index.ts +85 -0
- package/src/ports/asset-store.ts +36 -0
- package/src/ports/auth-provider.ts +32 -0
- package/src/ports/content-repository.ts +59 -0
- package/src/schema/define-config.ts +96 -0
- package/src/serialization/json-serializer.ts +87 -0
- package/src/serialization/serializer.ts +65 -0
- package/src/testing/index.ts +72 -0
- package/src/validation/schema-to-zod.test.ts +133 -0
- package/src/validation/schema-to-zod.ts +126 -0
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
|
+
})
|