includio-cms 0.14.0 → 0.14.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/DOCS.md +4540 -0
  3. package/README.md +37 -42
  4. package/ROADMAP.md +13 -0
  5. package/dist/admin/client/account/preferences-section.svelte +1 -0
  6. package/dist/admin/client/account/profile-section.svelte +4 -1
  7. package/dist/admin/client/account/security-section.svelte +2 -2
  8. package/dist/admin/client/entry/entry-form.svelte +1 -1
  9. package/dist/admin/client/users/users-page.svelte +9 -9
  10. package/dist/admin/components/fields/blocks-field.svelte +1 -1
  11. package/dist/admin/components/fields/media-field.svelte +1 -0
  12. package/dist/admin/components/layout/layout-renderer.svelte +2 -1
  13. package/dist/admin/components/layout/layout-renderer.svelte.d.ts +1 -0
  14. package/dist/admin/components/media/bulk-action-bar.svelte +2 -0
  15. package/dist/admin/components/media/file/file-details.svelte +1 -0
  16. package/dist/admin/components/media/focal-point-input.svelte +3 -2
  17. package/dist/admin/components/media/tag-sidebar.svelte +1 -0
  18. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +2 -1
  19. package/dist/components/ui/dropdown-menu/index.d.ts +17 -0
  20. package/dist/components/ui/spinner/spinner.svelte +2 -2
  21. package/dist/components/ui/spinner/spinner.svelte.d.ts +4 -0
  22. package/dist/core/server/fields/utils/imageStyles.js +5 -4
  23. package/dist/core/server/media/styles/sharp/generateImageStyle.js +27 -0
  24. package/dist/db-postgres/index.js +4 -4
  25. package/dist/db-postgres/schema/imageStyle.d.ts +17 -0
  26. package/dist/db-postgres/schema/imageStyle.js +3 -2
  27. package/dist/demo/seed.js +0 -1
  28. package/dist/sveltekit/components/cms-provider.svelte +17 -0
  29. package/dist/sveltekit/components/cms-provider.svelte.d.ts +12 -0
  30. package/dist/sveltekit/components/structured-content.svelte +1 -0
  31. package/dist/sveltekit/components/video-context.d.ts +2 -0
  32. package/dist/sveltekit/components/video-context.js +8 -0
  33. package/dist/sveltekit/components/video.svelte +12 -4
  34. package/dist/sveltekit/index.d.ts +2 -0
  35. package/dist/sveltekit/index.js +2 -0
  36. package/dist/sveltekit/server/handle.js +9 -0
  37. package/dist/sveltekit/server/index.d.ts +1 -0
  38. package/dist/sveltekit/server/index.js +1 -0
  39. package/dist/sveltekit/server/layout.d.ts +4 -0
  40. package/dist/sveltekit/server/layout.js +5 -0
  41. package/dist/types/cms-context.d.ts +3 -0
  42. package/dist/types/cms-context.js +1 -0
  43. package/dist/types/fields.d.ts +1 -0
  44. package/dist/types/index.d.ts +1 -0
  45. package/dist/types/index.js +1 -0
  46. package/dist/updates/0.14.1/index.d.ts +2 -0
  47. package/dist/updates/0.14.1/index.js +15 -0
  48. package/dist/updates/0.14.2/index.d.ts +2 -0
  49. package/dist/updates/0.14.2/index.js +18 -0
  50. package/dist/updates/index.js +3 -1
  51. package/package.json +8 -2
package/DOCS.md ADDED
@@ -0,0 +1,4540 @@
1
+ # Includio CMS Documentation (v0.14.2)
2
+
3
+ > This file is auto-generated from the docs site. For the latest version, update the package.
4
+
5
+ # Includio CMS
6
+
7
+ A headless CMS built for SvelteKit. Type-safe, extensible, with a modern admin interface.
8
+
9
+ > **Beta:** Includio is in active development. Some APIs may change between versions.
10
+
11
+ ## Features
12
+
13
+ - **Type-safe Configuration** — Define content schemas with full TypeScript support and IDE autocompletion.
14
+ - **Built for SvelteKit** — First-class integration with SvelteKit's architecture, hooks, and routing.
15
+ - **19 Field Types** — Text, structured content, media, relations, blocks, SEO, and more out of the box.
16
+ - **Content Versioning** — Draft, published, and scheduled states for every entry.
17
+ - **Media Management** — Upload, organize with tags, transform images with Sharp, transcode videos with ffmpeg.
18
+ - **Pluggable Adapters** — Swap database, file storage, email, and AI providers independently.
19
+ - **Multi-language** — Per-field localization with any number of languages.
20
+ - **Plugins & Hooks** — Lifecycle hooks for extending CMS behavior (webhooks, logging, etc).
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ pnpm add includio-cms
26
+ ```
27
+
28
+ ```typescript
29
+ import { defineConfig } from 'includio-cms/sveltekit';
30
+ import { pg } from 'includio-cms/db-postgres';
31
+ import { local } from 'includio-cms/files-local';
32
+ import { nodemailerAdapter } from 'includio-cms/email-nodemailer';
33
+
34
+ export default defineConfig({
35
+ languages: ['en'],
36
+ db: pg({ databaseUrl: process.env.DATABASE_URL }),
37
+ files: local(),
38
+ email: nodemailerAdapter({
39
+ defaultFromAddress: 'noreply@example.com',
40
+ defaultFromName: 'My Site',
41
+ transportOptions: { host: 'smtp.example.com', port: 587, auth: { user: 'user', pass: 'pass' } }
42
+ }),
43
+ collections: [/* ... */],
44
+ singles: [/* ... */],
45
+ });
46
+ ```
47
+
48
+ See [Getting Started](/docs/getting-started) for full installation instructions.
49
+
50
+
51
+ ---
52
+
53
+ # Getting Started
54
+
55
+ > **Prerequisites:** You need Node.js 18+, a SvelteKit project, and a PostgreSQL database before continuing.
56
+
57
+ ## 1. Install
58
+
59
+ ```bash
60
+ pnpm add includio-cms
61
+ ```
62
+
63
+ ## 2. Create CMS Config
64
+
65
+ Create `src/cms/cms.config.ts`:
66
+
67
+ ```typescript
68
+ import { defineConfig } from 'includio-cms/sveltekit';
69
+ import { pg } from 'includio-cms/db-postgres';
70
+ import { local } from 'includio-cms/files-local';
71
+ import { nodemailerAdapter } from 'includio-cms/email-nodemailer';
72
+
73
+ export default defineConfig({
74
+ languages: ['en'],
75
+ db: pg({
76
+ databaseUrl: process.env.DATABASE_URL
77
+ }),
78
+ files: local(),
79
+ email: nodemailerAdapter({
80
+ defaultFromAddress: process.env.SMTP_FROM,
81
+ defaultFromName: 'My Site',
82
+ transportOptions: {
83
+ host: process.env.SMTP_HOST,
84
+ port: 587,
85
+ auth: {
86
+ user: process.env.SMTP_USER,
87
+ pass: process.env.SMTP_PASS
88
+ }
89
+ }
90
+ }),
91
+ collections: [],
92
+ singles: [],
93
+ forms: [],
94
+ plugins: []
95
+ });
96
+ ```
97
+
98
+ ## 3. Initialize in hooks.server.ts
99
+
100
+ Add to `src/hooks.server.ts`:
101
+
102
+ ```typescript
103
+ import { includioCMS } from 'includio-cms/sveltekit/server';
104
+ import { sequence } from '@sveltejs/kit/hooks';
105
+ import config from '$cms/cms.config';
106
+
107
+ export const handle = sequence(...includioCMS(config));
108
+ ```
109
+
110
+ This initializes the CMS, sets up authentication, admin route protection, security headers, and code generation. It runs once on server startup.
111
+
112
+ > **SvelteKit Alias:** The `$cms` alias maps to `src/cms` — configured via `kit.alias` in `svelte.config.js`.
113
+
114
+ ## 4. Add Layout Load
115
+
116
+ Create `src/routes/+layout.server.ts` to pass CMS context (e.g. Safari video preference) to the frontend:
117
+
118
+ ```typescript
119
+ import { cmsLayoutLoad } from 'includio-cms/sveltekit/server';
120
+
121
+ export function load(event) {
122
+ return cmsLayoutLoad(event);
123
+ }
124
+ ```
125
+
126
+ Then wrap your root layout with `CmsProvider`:
127
+
128
+ ```svelte
129
+ <!-- src/routes/+layout.svelte -->
130
+ <CmsProvider {data}>
131
+ {@render children()}
132
+ </CmsProvider>
133
+ ```
134
+
135
+ ## 5. Add Admin Routes
136
+
137
+ The admin panel mounts at `/admin`. Import the admin layout and pages from `includio-cms/admin`.
138
+
139
+ ## 6. Run Migrations
140
+
141
+ ```bash
142
+ pnpm db:push
143
+ ```
144
+
145
+ This creates all required database tables (entries, versions, media, forms, etc).
146
+
147
+ ## 7. Create First User
148
+
149
+ ```bash
150
+ pnpm create:user
151
+ ```
152
+
153
+ Follow the prompts to create an admin user with email and password.
154
+
155
+ ## Next Steps
156
+
157
+ - [Configuration](/docs/getting-started/configuration) — Full config reference
158
+ - [Collections](/docs/collections) — Define repeatable content types
159
+ - [Singles](/docs/singles) — One-off content like homepage settings
160
+ - [Fields](/docs/fields) — All 19 field types
161
+ - [Frontend Rendering](/docs/frontend) — Components for SvelteKit
162
+ - [Migration Guide](/docs/migration) — Version upgrade guide
163
+
164
+
165
+ ---
166
+
167
+ # Configuration
168
+
169
+ The CMS is configured using `defineConfig` from `includio-cms/sveltekit`.
170
+
171
+ ## CMSConfig Interface
172
+
173
+ ```typescript
174
+ interface CMSConfig {
175
+ languages: Language[];
176
+ collections?: CollectionConfig[];
177
+ singles?: SingleConfig[];
178
+ forms?: FormConfig[];
179
+ db: DatabaseAdapter;
180
+ files: FilesAdapter;
181
+ email?: EmailAdapter;
182
+ auth?: AuthConfig;
183
+ plugins?: PluginConfig[];
184
+ ai?: AIAdapter;
185
+ media?: MediaConfig;
186
+ apiKeys?: ApiKeyConfig[];
187
+ typography?: TypographyConfig;
188
+ }
189
+ ```
190
+
191
+ ## Options
192
+
193
+ | Property | Type | Required | Description |
194
+ |----------|------|----------|-------------|
195
+ | `languages` | `Language[]` | Yes | Language codes. First is default. |
196
+ | `collections` | `CollectionConfig[]` | No | Repeatable content types |
197
+ | `singles` | `SingleConfig[]` | No | One-off content types |
198
+ | `forms` | `FormConfig[]` | No | Form submission handlers |
199
+ | `db` | `DatabaseAdapter` | Yes | Database adapter instance |
200
+ | `files` | `FilesAdapter` | Yes | File storage adapter instance |
201
+ | `email` | `EmailAdapter` | No | Email sending adapter (for notifications, password reset) |
202
+ | `auth` | `AuthConfig` | No | Authentication configuration |
203
+ | `plugins` | `PluginConfig[]` | No | Lifecycle hook plugins |
204
+ | `ai` | `AIAdapter` | No | AI provider (alt text generation) |
205
+ | `media` | `MediaConfig` | No | Media upload constraints |
206
+ | `apiKeys` | `ApiKeyConfig[]` | No | API keys for REST API access |
207
+ | `typography` | `TypographyConfig` | No | Typography settings |
208
+
209
+ ## Languages
210
+
211
+ Define supported languages as an array of ISO codes. The first language is the default.
212
+
213
+ ```typescript
214
+ defineConfig({
215
+ languages: ['en', 'pl', 'de'],
216
+ // ...
217
+ });
218
+ ```
219
+
220
+ Fields with `localized: true` will store separate values per language.
221
+
222
+ ## Auth
223
+
224
+ ```typescript
225
+ interface AuthConfig {
226
+ secret: string; // Session secret
227
+ baseURL?: string; // Base URL for auth callbacks
228
+ }
229
+ ```
230
+
231
+ ```typescript
232
+ defineConfig({
233
+ auth: {
234
+ secret: process.env.AUTH_SECRET
235
+ },
236
+ // ...
237
+ });
238
+ ```
239
+
240
+ ## API Keys
241
+
242
+ Enable REST API access with API keys:
243
+
244
+ ```typescript
245
+ interface ApiKeyConfig {
246
+ key: string;
247
+ name?: string;
248
+ role?: 'admin' | 'editor';
249
+ }
250
+ ```
251
+
252
+ ```typescript
253
+ defineConfig({
254
+ apiKeys: [
255
+ { key: process.env.API_KEY, name: 'Frontend', role: 'editor' }
256
+ ],
257
+ // ...
258
+ });
259
+ ```
260
+
261
+ See [API Reference](/docs/api) for REST API endpoints.
262
+
263
+ ## Media Config
264
+
265
+ ```typescript
266
+ interface MediaConfig {
267
+ maxOriginalWidth?: number;
268
+ maxOriginalHeight?: number;
269
+ video?: VideoTranscodeConfig;
270
+ }
271
+
272
+ interface VideoTranscodeConfig {
273
+ transcode?: boolean; // default: true
274
+ formats?: ('mp4' | 'webm')[]; // default: ['mp4', 'webm']
275
+ maxResolution?: number; // default: 1080 (px, longest side)
276
+ crf?: { mp4?: number; webm?: number }; // default: mp4=23, webm=30
277
+ concurrency?: number; // default: 1 (parallel jobs)
278
+ }
279
+ ```
280
+
281
+ Example with video transcoding:
282
+
283
+ ```typescript
284
+ defineConfig({
285
+ media: {
286
+ maxOriginalWidth: 4096,
287
+ video: {
288
+ transcode: true,
289
+ formats: ['mp4', 'webm'],
290
+ maxResolution: 1080,
291
+ crf: { mp4: 23, webm: 30 }
292
+ }
293
+ }
294
+ });
295
+ ```
296
+
297
+ See [Video Transcoding](/docs/video-transcoding) for full details.
298
+
299
+ ## Typography
300
+
301
+ ```typescript
302
+ interface TypographyConfig {
303
+ fixOrphans?: boolean; // default: true — prevents widow words
304
+ }
305
+ ```
306
+
307
+ ## Adapters
308
+
309
+ ### Database
310
+
311
+ ```typescript
312
+ import { pg } from 'includio-cms/db-postgres';
313
+
314
+ db: pg({
315
+ databaseUrl: 'postgres://user:pass@localhost:5432/mydb'
316
+ })
317
+ ```
318
+
319
+ See [Database Adapter](/docs/adapters/database) for the full interface.
320
+
321
+ ### File Storage
322
+
323
+ ```typescript
324
+ import { local } from 'includio-cms/files-local';
325
+
326
+ files: local()
327
+ ```
328
+
329
+ See [Files Adapter](/docs/adapters/files) for the full interface.
330
+
331
+ ### Email
332
+
333
+ ```typescript
334
+ import { nodemailerAdapter } from 'includio-cms/email-nodemailer';
335
+
336
+ email: nodemailerAdapter({
337
+ defaultFromAddress: 'noreply@example.com',
338
+ defaultFromName: 'My Site',
339
+ transportOptions: {
340
+ host: 'smtp.example.com',
341
+ port: 587,
342
+ auth: { user: 'user', pass: 'pass' }
343
+ }
344
+ })
345
+ ```
346
+
347
+ See [Email Adapter](/docs/adapters/email) for the full interface.
348
+
349
+ ### AI (Optional)
350
+
351
+ ```typescript
352
+ import { claudeAdapter } from 'includio-cms/ai-claude';
353
+ // or
354
+ import { openAIAdapter } from 'includio-cms/ai-openai';
355
+
356
+ ai: claudeAdapter({
357
+ apiKey: process.env.ANTHROPIC_API_KEY
358
+ })
359
+ ```
360
+
361
+ Currently supports automatic alt text generation for uploaded images.
362
+
363
+ ## Helper Functions
364
+
365
+ | Function | Purpose |
366
+ |----------|---------|
367
+ | `defineConfig` | Type-safe CMS configuration |
368
+ | `defineCollection` | Type-safe collection definition |
369
+ | `defineSingle` | Type-safe single definition |
370
+ | `defineForm` | Type-safe form definition |
371
+ | `defineObject` | Type-safe object field shorthand |
372
+
373
+ > **Environment Variables:** Use `process.env` for secrets like database URLs, API keys, and SMTP credentials. Never hardcode them in your config file.
374
+
375
+
376
+ ---
377
+
378
+ # Layout
379
+
380
+ Customize how fields are arranged in the admin editor. Layouts apply to both collections and singles via the `layout` property.
381
+
382
+ ## Presets
383
+
384
+ Quick layout presets for common patterns:
385
+
386
+ ```typescript
387
+ defineCollection({
388
+ slug: 'posts',
389
+ layout: 'sidebar-right',
390
+ // ...
391
+ });
392
+ ```
393
+
394
+ | Preset | Description |
395
+ |--------|-------------|
396
+ | `'sidebar-right'` | Main fields left, metadata sidebar right |
397
+ | `'two-column'` | Balanced two-column layout |
398
+
399
+ ### Preset with Options
400
+
401
+ ```typescript
402
+ // Specify which fields go in the sidebar
403
+ layout: { preset: 'sidebar-right', sidebar: ['seo', 'category', 'publishedAt'] }
404
+
405
+ // Specify which fields go in the left column
406
+ layout: { preset: 'two-column', left: ['title', 'content'] }
407
+ ```
408
+
409
+ ## Custom Layout
410
+
411
+ For full control, define an array of layout nodes:
412
+
413
+ ```typescript
414
+ defineCollection({
415
+ slug: 'page',
416
+ layout: [
417
+ {
418
+ type: 'columns',
419
+ ratio: '2fr 1fr',
420
+ children: [
421
+ {
422
+ type: 'stack',
423
+ fields: ['title', 'content', 'blocks']
424
+ },
425
+ {
426
+ type: 'stack',
427
+ children: [
428
+ {
429
+ type: 'card',
430
+ label: 'SEO',
431
+ fields: ['seo']
432
+ },
433
+ {
434
+ type: 'card',
435
+ label: 'Settings',
436
+ fields: ['category', 'publishedAt', 'featured']
437
+ }
438
+ ]
439
+ }
440
+ ]
441
+ }
442
+ ],
443
+ fields: [/* ... */]
444
+ });
445
+ ```
446
+
447
+ ## Node Types
448
+
449
+ ### Section
450
+
451
+ Labeled grouping with optional icon:
452
+
453
+ ```typescript
454
+ {
455
+ type: 'section',
456
+ label: { en: 'Hero', pl: 'Baner' },
457
+ icon: 'photo',
458
+ fields: ['heroTitle', 'heroSubtitle', 'heroImage']
459
+ }
460
+ ```
461
+
462
+ ### Columns
463
+
464
+ Grid layout with configurable column ratios:
465
+
466
+ ```typescript
467
+ {
468
+ type: 'columns',
469
+ ratio: '2fr 1fr',
470
+ children: [/* layout nodes */]
471
+ }
472
+ ```
473
+
474
+ Available ratios: `'1fr 1fr'`, `'2fr 1fr'`, `'1fr 2fr'`, `'3fr 1fr'`, `'1fr 3fr'`, `'1fr 1fr 1fr'`
475
+
476
+ ### Card
477
+
478
+ Bordered card with label, optional auto-grid:
479
+
480
+ ```typescript
481
+ {
482
+ type: 'card',
483
+ label: 'Contact Info',
484
+ autoGrid: true,
485
+ fields: ['email', 'phone', 'address']
486
+ }
487
+ ```
488
+
489
+ ### Accordion
490
+
491
+ Collapsible section:
492
+
493
+ ```typescript
494
+ {
495
+ type: 'accordion',
496
+ label: 'Advanced Settings',
497
+ defaultOpen: false,
498
+ fields: ['customCode', 'tracking']
499
+ }
500
+ ```
501
+
502
+ ### Stack
503
+
504
+ Simple vertical stacking (no visual container):
505
+
506
+ ```typescript
507
+ {
508
+ type: 'stack',
509
+ fields: ['title', 'content']
510
+ }
511
+ ```
512
+
513
+ ## Node Properties
514
+
515
+ All nodes share:
516
+
517
+ | Property | Type | Description |
518
+ |----------|------|-------------|
519
+ | `type` | `string` | Node type |
520
+ | `label` | `Localized` | Display label (required for section, card, accordion) |
521
+ | `icon` | `string` | Optional icon |
522
+ | `fields` | `string[]` | Field slugs to render in this node |
523
+ | `children` | `LayoutNode[]` | Nested layout nodes |
524
+
525
+ A node can have `fields`, `children`, or both.
526
+
527
+ ## Dot-Notation for Object Fields
528
+
529
+ Reference nested object fields using dot-notation:
530
+
531
+ ```typescript
532
+ {
533
+ type: 'card',
534
+ label: 'Company',
535
+ fields: ['companyInfo.name', 'companyInfo.motto', 'companyInfo.contact.email']
536
+ }
537
+ ```
538
+
539
+ This extracts specific fields from object types and places them in the layout, regardless of the object's nesting level.
540
+
541
+ > **Combining Layouts and Fields:** Fields not referenced in any layout node are rendered at the bottom in their original order. You don't need to include every field in the layout — only the ones you want to position specifically.
542
+
543
+
544
+ ---
545
+
546
+ # Collections
547
+
548
+ Collections are repeatable content types. Each collection can have multiple entries (e.g., blog posts, products, team members).
549
+
550
+ ## Defining a Collection
551
+
552
+ ```typescript
553
+ import { defineCollection } from 'includio-cms/sveltekit';
554
+
555
+ const posts = defineCollection({
556
+ slug: 'posts',
557
+ labels: {
558
+ singular: { en: 'Post', pl: 'Wpis' },
559
+ plural: { en: 'Posts', pl: 'Wpisy' }
560
+ },
561
+ entryAdminTitle: 'title',
562
+ fields: [
563
+ { type: 'text', slug: 'title', required: true },
564
+ { type: 'slug', slug: 'slug' },
565
+ { type: 'content', slug: 'content' },
566
+ { type: 'media', slug: 'cover', accept: 'image/*', styles: [
567
+ { name: 'thumbnail', width: 400, height: 300, crop: true },
568
+ { name: 'hero', width: 1200, height: 630, crop: true }
569
+ ]},
570
+ { type: 'relation', slug: 'author', collection: 'team', displayField: 'name' },
571
+ { type: 'select', slug: 'category', options: [
572
+ { label: 'Tech', value: 'tech' },
573
+ { label: 'Design', value: 'design' }
574
+ ]},
575
+ { type: 'date', slug: 'publishedAt' },
576
+ { type: 'seo', slug: 'seo' }
577
+ ]
578
+ });
579
+ ```
580
+
581
+ ## Configuration
582
+
583
+ | Property | Type | Required | Description |
584
+ |----------|------|----------|-------------|
585
+ | `slug` | `string` | Yes | Unique identifier for the collection |
586
+ | `fields` | `Field[]` | Yes | Array of field definitions |
587
+ | `labels` | `{ singular?: Localized; plural?: Localized }` | No | Display labels in admin |
588
+ | `entryAdminTitle` | `string` | No | Field slug used as entry title in admin list |
589
+ | `sidebarIcon` | `IconName` | No | Icon for the admin sidebar |
590
+ | `previewUrl` | `string` | No | URL pattern for previewing entries |
591
+ | `orderable` | `boolean` | No | Enable manual drag-and-drop ordering |
592
+ | `listColumns` | `string[]` | No | Field slugs to display as columns in admin list |
593
+ | `layout` | `Layout` | No | Admin editor [layout](/docs/getting-started/layout) |
594
+ | `slugField` | `string` | No | Dot-path to slug field (default: `'seo.slug'`) |
595
+ | `pathTemplate` | `string` | No | URL path template, e.g. `'blog/{slug}'` |
596
+
597
+ > **entryAdminTitle:** Set `entryAdminTitle` to a text field slug (like `'title'`) so entries show meaningful names in the admin list instead of IDs.
598
+
599
+ ## Usage in Config
600
+
601
+ ```typescript
602
+ export default defineConfig({
603
+ collections: [posts, products, team],
604
+ // ...
605
+ });
606
+ ```
607
+
608
+ ## Querying Entries
609
+
610
+ ```typescript
611
+ import { getEntries } from 'includio-cms/admin/remote';
612
+
613
+ // All published posts
614
+ const posts = await getEntries({
615
+ slug: 'posts',
616
+ status: 'published',
617
+ language: 'en'
618
+ });
619
+
620
+ // Filter by field values
621
+ const techPosts = await getEntries({
622
+ slug: 'posts',
623
+ status: 'published',
624
+ dataValues: { category: 'tech' }
625
+ });
626
+
627
+ // Search by field content
628
+ const results = await getEntries({
629
+ slug: 'posts',
630
+ dataLike: { title: 'svelte' }
631
+ });
632
+ ```
633
+
634
+ > **Slug Uniqueness:** Collection slugs must be unique across all collections and singles. The slug is used as the database identifier for entries.
635
+
636
+
637
+ ---
638
+
639
+ # Singles
640
+
641
+ Singles are one-off content types. Unlike collections, a single has exactly one entry (e.g., homepage settings, site configuration, about page).
642
+
643
+ ## Defining a Single
644
+
645
+ ```typescript
646
+ import { defineSingle } from 'includio-cms/sveltekit';
647
+
648
+ const homepage = defineSingle({
649
+ slug: 'homepage',
650
+ label: { en: 'Homepage', pl: 'Strona główna' },
651
+ fields: [
652
+ { type: 'text', slug: 'heroTitle', required: true, label: 'Hero Title' },
653
+ { type: 'text', slug: 'heroSubtitle', multiline: true },
654
+ { type: 'media', slug: 'heroImage', accept: 'image/*', styles: [
655
+ { name: 'desktop', width: 1920, height: 800, crop: true },
656
+ { name: 'mobile', width: 768, height: 600, crop: true }
657
+ ]},
658
+ { type: 'blocks', slug: 'features', of: [
659
+ { type: 'object', slug: 'feature', fields: [
660
+ { type: 'text', slug: 'title', required: true },
661
+ { type: 'text', slug: 'description', multiline: true },
662
+ { type: 'media', slug: 'icon', accept: 'image/*' }
663
+ ]}
664
+ ]}
665
+ ]
666
+ });
667
+ ```
668
+
669
+ ## Configuration
670
+
671
+ | Property | Type | Required | Description |
672
+ |----------|------|----------|-------------|
673
+ | `slug` | `string` | Yes | Unique identifier |
674
+ | `fields` | `Field[]` | Yes | Array of field definitions |
675
+ | `label` | `Localized` | No | Display label in admin |
676
+ | `sidebarIcon` | `IconName` | No | Icon for admin sidebar |
677
+ | `previewUrl` | `string` | No | URL for previewing |
678
+ | `layout` | `Layout` | No | Admin editor [layout](/docs/getting-started/layout) |
679
+ | `slugField` | `string` | No | Dot-path to slug field (default: `'seo.slug'`) |
680
+ | `pathTemplate` | `string` | No | URL path template, e.g. `'about'` |
681
+
682
+ > **Auto-creation:** When you open a single in the admin panel, its entry is automatically created if it doesn't exist yet. No manual creation needed.
683
+
684
+ ## Behavior
685
+
686
+ - Singles always have exactly one entry
687
+ - They support the same versioning as collections (draft/published/scheduled)
688
+ - The admin shows a single editor view instead of a list
689
+
690
+ ## Usage in Config
691
+
692
+ ```typescript
693
+ export default defineConfig({
694
+ singles: [homepage, siteSettings, aboutPage, footer],
695
+ // ...
696
+ });
697
+ ```
698
+
699
+ ## Querying
700
+
701
+ ```typescript
702
+ import { getEntry } from 'includio-cms/admin/remote';
703
+
704
+ const homepage = await getEntry({
705
+ slug: 'homepage',
706
+ status: 'published',
707
+ language: 'en'
708
+ });
709
+ ```
710
+
711
+
712
+ ---
713
+
714
+ # Fields
715
+
716
+ Fields define the schema of your content. Includio provides 19 field types (18 built-in + custom) covering common content modeling needs.
717
+
718
+ ## Common Properties
719
+
720
+ All fields share these base properties:
721
+
722
+ | Property | Type | Required | Description |
723
+ |----------|------|----------|-------------|
724
+ | `type` | `FieldType` | Yes | The field type identifier |
725
+ | `slug` | `string` | Yes | Unique field identifier within the parent |
726
+ | `label` | `Localized` | No | Display label in admin UI |
727
+ | `required` | `boolean` | No | Whether the field must have a value |
728
+ | `description` | `Localized` | No | Help text shown below the field |
729
+ | `defaultValue` | `any` | No | Default value for new entries |
730
+ | `localized` | `boolean` | No | Enable per-language values |
731
+ | `showWhen` | `FieldCondition` | No | Conditionally show/hide this field |
732
+
733
+ ### Conditional Fields (`showWhen`)
734
+
735
+ Show or hide a field based on the value of a sibling field:
736
+
737
+ ```typescript
738
+ {
739
+ type: 'text',
740
+ slug: 'customUrl',
741
+ label: 'Custom URL',
742
+ showWhen: { field: 'linkType', equals: 'custom' }
743
+ }
744
+ ```
745
+
746
+ ```typescript
747
+ interface FieldCondition {
748
+ field: string; // slug of a sibling field
749
+ equals?: string | string[]; // show when value matches
750
+ notEquals?: string | string[]; // show when value does NOT match
751
+ }
752
+ ```
753
+
754
+ > **Localized Fields:** When `localized: true`, the admin shows separate inputs for each configured language. The stored data is keyed by language code.
755
+
756
+ > **Required Fields:** Fields with `required: true` display a red asterisk (*) next to their label. Validation messages are shown in Polish and summarized in toast notifications.
757
+
758
+ ## Available Types
759
+
760
+ | Type | Description | Output |
761
+ |------|-------------|--------|
762
+ | [Text](/docs/fields/text) | Single/multiline text | `string` |
763
+ | [Content](/docs/fields/content) | Structured rich text editor | `StructuredContentDoc` |
764
+ | [Number](/docs/fields/number) | Numeric input | `number` |
765
+ | [Boolean](/docs/fields/boolean) | Toggle switch | `boolean` |
766
+ | [Date](/docs/fields/date) | Date picker | `string` (ISO) |
767
+ | [DateTime](/docs/fields/datetime) | Date + time picker | `string` (ISO) |
768
+ | [File](/docs/fields/file) | File upload | `string \| string[]` |
769
+ | [Media](/docs/fields/media-field) | Image/video upload + styles | `MediaFieldData` |
770
+ | [Select](/docs/fields/select) | Dropdown select | `string \| string[]` |
771
+ | [Radio](/docs/fields/radio) | Radio button group | `string` |
772
+ | [Checkboxes](/docs/fields/checkboxes) | Checkbox group | `string[]` |
773
+ | [Relation](/docs/fields/relation) | Reference to entries | `string \| string[]` |
774
+ | [Object](/docs/fields/object) | Nested field group | `ObjectFieldData` |
775
+ | [Array](/docs/fields/array) | Simple typed arrays | `(string \| number \| UrlFieldData)[]` |
776
+ | [Blocks](/docs/fields/blocks) | Repeatable typed objects | `ObjectFieldData[]` |
777
+ | [Slug](/docs/fields/slug) | URL-friendly slug | `string` |
778
+ | [SEO](/docs/fields/seo) | SEO metadata group | `SeoFieldData` |
779
+ | [URL](/docs/fields/url) | URL per locale | `UrlFieldData` |
780
+
781
+
782
+ ---
783
+
784
+ # Entries
785
+
786
+ Entries are the content records stored in your database. Both collections and singles produce entries.
787
+
788
+ ## Entry Types
789
+
790
+ ### Populated Entry (API output)
791
+
792
+ When fetching entries via `getEntries` / `getEntry`, you receive populated entries with metadata prefixed by `_`:
793
+
794
+ ```typescript
795
+ type Entry = {
796
+ _id: string;
797
+ _slug: string; // Collection/single slug
798
+ _type: 'collection' | 'singleton';
799
+ _publishedAt?: Date | null;
800
+ _url?: string; // Generated from pathTemplate, e.g. '/blog/my-post'
801
+ } & Record<string, unknown>; // Your field data
802
+ ```
803
+
804
+ ### Database Entry
805
+
806
+ The raw database structure:
807
+
808
+ ```typescript
809
+ interface DbEntry {
810
+ id: string;
811
+ slug: string; // Collection/single slug
812
+ type: 'collection' | 'singleton';
813
+ createdAt: Date;
814
+ updatedAt: Date;
815
+ archivedAt: Date | null;
816
+ sortOrder: number | null; // For orderable collections
817
+ }
818
+ ```
819
+
820
+ ## Versioning
821
+
822
+ Each entry has per-language versions. Every language can have its own draft, published, and scheduled version independently.
823
+
824
+ ```typescript
825
+ interface DbEntryVersion {
826
+ id: string;
827
+ entryId: string;
828
+ lang: string; // Language code (e.g. 'en', 'pl')
829
+ versionNumber: number;
830
+ data: Record<string, unknown>;
831
+ createdAt: Date;
832
+ createdBy: string | null;
833
+ publishedAt: Date | null;
834
+ publishedBy: string | null;
835
+ }
836
+ ```
837
+
838
+ ### Version Flow
839
+
840
+ An entry progresses through these states:
841
+
842
+ 1. **Draft** — Work in progress, not visible to public
843
+ 2. **Published** — Live content, served to visitors
844
+ 3. **Scheduled** — Will be published at a future date/time
845
+
846
+ Only one version per status per language can exist at a time.
847
+
848
+ ### Operations
849
+
850
+ | Action | Effect |
851
+ |--------|--------|
852
+ | Save as Draft | Creates/updates draft version for the current language |
853
+ | Publish Now | Creates new version, sets as published immediately |
854
+ | Unpublish | Removes published status from current version |
855
+ | Archive | Soft-deletes entry (can be restored) |
856
+ | Restore | Removes archived status |
857
+ | Delete permanently | Irreversible deletion from database |
858
+
859
+ > **Archiving:** Archived entries are hidden from the admin list and API queries by default. Only archived entries can be permanently deleted.
860
+
861
+ ## Querying
862
+
863
+ ```typescript
864
+ import { getEntries, getEntry } from 'includio-cms/admin/remote';
865
+
866
+ // Get published entries for a collection
867
+ const posts = await getEntries({
868
+ slug: 'posts',
869
+ status: 'published',
870
+ language: 'en'
871
+ });
872
+
873
+ // Get a single entry
874
+ const homepage = await getEntry({
875
+ slug: 'homepage',
876
+ status: 'published',
877
+ language: 'en'
878
+ });
879
+
880
+ // Filter by exact field values
881
+ const featured = await getEntries({
882
+ slug: 'posts',
883
+ dataValues: { featured: true }
884
+ });
885
+
886
+ // Search by field content (LIKE query)
887
+ const results = await getEntries({
888
+ slug: 'posts',
889
+ dataLike: { title: 'svelte' }
890
+ });
891
+
892
+ // Case-insensitive OR search across fields
893
+ const search = await getEntries({
894
+ slug: 'posts',
895
+ dataILikeOr: { title: 'svelte', description: 'svelte' }
896
+ });
897
+
898
+ // Sort by a data field
899
+ const sorted = await getEntries({
900
+ slug: 'events',
901
+ status: 'published',
902
+ language: 'en',
903
+ dataOrderBy: { field: 'eventDate', direction: 'asc' }
904
+ });
905
+
906
+ // Pagination
907
+ const page = await getEntries({
908
+ slug: 'posts',
909
+ status: 'published',
910
+ language: 'en',
911
+ limit: 10,
912
+ offset: 20,
913
+ orderBy: { column: 'createdAt', direction: 'desc' }
914
+ });
915
+ ```
916
+
917
+ ## Query Options
918
+
919
+ | Option | Type | Description |
920
+ |--------|------|-------------|
921
+ | `slug` | `string` | Collection/single slug |
922
+ | `ids` | `string[]` | Filter by specific entry IDs |
923
+ | `status` | `'draft' \| 'published' \| 'scheduled' \| 'archived'` | Version status |
924
+ | `language` | `string` | Locale for localized fields |
925
+ | `dataValues` | `Record<string, unknown>` | Exact field value matching |
926
+ | `dataLike` | `Record<string, unknown>` | Partial text matching (LIKE) |
927
+ | `dataILikeOr` | `Record<string, unknown>` | Case-insensitive OR search |
928
+ | `orderBy` | `{ column, direction }` | Sort by `'createdAt'`, `'updatedAt'`, or `'sortOrder'` |
929
+ | `dataOrderBy` | `{ field, direction }` | Sort by a JSON data field |
930
+ | `limit` | `number` | Max entries to return |
931
+ | `offset` | `number` | Skip N entries (pagination) |
932
+
933
+ ## Using Entries on Frontend
934
+
935
+ In a SvelteKit `+page.server.ts` load function:
936
+
937
+ ```typescript
938
+ import { getEntries, getEntry } from 'includio-cms/admin/remote';
939
+
940
+ export async function load() {
941
+ const posts = await getEntries({
942
+ slug: 'posts',
943
+ status: 'published',
944
+ language: 'en',
945
+ limit: 10,
946
+ orderBy: { column: 'createdAt', direction: 'desc' }
947
+ });
948
+
949
+ return { posts };
950
+ }
951
+ ```
952
+
953
+ Each entry in the result contains your field data plus metadata:
954
+
955
+ ```typescript
956
+ // posts[0] example:
957
+ {
958
+ _id: "abc-123",
959
+ _slug: "posts",
960
+ _type: "collection",
961
+ _publishedAt: "2026-03-15T10:00:00Z",
962
+ _url: "/blog/my-first-post",
963
+ title: "My First Post",
964
+ content: { type: "doc", content: [...] }, // StructuredContentDoc
965
+ cover: { data: { ... }, styles: { ... } }, // MediaFieldData
966
+ category: "tech"
967
+ }
968
+ ```
969
+
970
+ > **pathTemplate:** The `_url` field is auto-generated from the collection's `pathTemplate` (e.g. `'blog/&#123;slug&#125;'`) and the entry's slug field. Configure it in your collection/single definition.
971
+
972
+
973
+ ---
974
+
975
+ # Text Field
976
+
977
+ Single or multiline text input. The most common field type for titles, descriptions, and short content.
978
+
979
+ ## Basic Usage
980
+
981
+ ```typescript
982
+ {
983
+ type: 'text',
984
+ slug: 'title',
985
+ label: 'Title',
986
+ required: true,
987
+ placeholder: 'Enter title...'
988
+ }
989
+ ```
990
+
991
+ ## With Options
992
+
993
+ ```typescript
994
+ {
995
+ type: 'text',
996
+ slug: 'excerpt',
997
+ label: 'Excerpt',
998
+ multiline: true,
999
+ maxLength: 500,
1000
+ description: 'Brief summary for listing pages'
1001
+ }
1002
+ ```
1003
+
1004
+ ## Properties
1005
+
1006
+ | Property | Type | Default | Description |
1007
+ |----------|------|---------|-------------|
1008
+ | `placeholder` | `string` | — | Input placeholder text |
1009
+ | `minLength` | `number` | — | Minimum character length |
1010
+ | `maxLength` | `number` | — | Maximum character length |
1011
+ | `pattern` | `string` | — | Regex validation pattern |
1012
+ | `multiline` | `boolean` | `false` | Render as textarea |
1013
+ | `defaultValue` | `string` | — | Default text value |
1014
+
1015
+ ## Output
1016
+
1017
+ Returns a `string`.
1018
+
1019
+ ```typescript
1020
+ // Single line
1021
+ { title: "My Blog Post" }
1022
+
1023
+ // Multiline
1024
+ { excerpt: "A brief description\nspanning multiple lines" }
1025
+ ```
1026
+
1027
+ > **Use Cases:** Use text for titles, subtitles, excerpts, and any short-form content. For longer structured content, use [Content](/docs/fields/content) instead.
1028
+
1029
+
1030
+ ---
1031
+
1032
+ # Content Field
1033
+
1034
+ Structured rich text editor powered by [TipTap](https://tiptap.dev/). Outputs a JSON document tree (`StructuredContentDoc`), not HTML.
1035
+
1036
+ ## Basic Usage
1037
+
1038
+ ```typescript
1039
+ {
1040
+ type: 'content',
1041
+ slug: 'body',
1042
+ label: 'Body',
1043
+ required: true
1044
+ }
1045
+ ```
1046
+
1047
+ ## With Inline Blocks
1048
+
1049
+ Embed custom data blocks inside the rich text flow:
1050
+
1051
+ ```typescript
1052
+ {
1053
+ type: 'content',
1054
+ slug: 'body',
1055
+ label: 'Body',
1056
+ inlineBlocks: [
1057
+ {
1058
+ type: 'object',
1059
+ slug: 'callout',
1060
+ label: 'Callout',
1061
+ fields: [
1062
+ { type: 'select', slug: 'variant', options: [
1063
+ { label: 'Info', value: 'info' },
1064
+ { label: 'Warning', value: 'warning' }
1065
+ ]},
1066
+ { type: 'text', slug: 'text', required: true }
1067
+ ]
1068
+ }
1069
+ ]
1070
+ }
1071
+ ```
1072
+
1073
+ ## Properties
1074
+
1075
+ | Property | Type | Default | Description |
1076
+ |----------|------|---------|-------------|
1077
+ | `inlineBlocks` | `ObjectField[]` | — | Custom block types embeddable in the content |
1078
+ | `defaultValue` | `StructuredContentDoc` | — | Default document |
1079
+
1080
+ ## Output
1081
+
1082
+ Returns a `StructuredContentDoc` — a JSON tree of nodes:
1083
+
1084
+ ```typescript
1085
+ interface StructuredContentDoc {
1086
+ type: 'doc';
1087
+ content: SCNode[];
1088
+ }
1089
+
1090
+ interface SCNode {
1091
+ type: string; // 'paragraph', 'heading', 'figure', etc.
1092
+ attrs?: Record<string, unknown>;
1093
+ content?: SCNode[];
1094
+ marks?: SCMark[]; // 'bold', 'italic', 'link', etc.
1095
+ text?: string;
1096
+ }
1097
+ ```
1098
+
1099
+ Example output:
1100
+
1101
+ ```json
1102
+ {
1103
+ "type": "doc",
1104
+ "content": [
1105
+ {
1106
+ "type": "heading",
1107
+ "attrs": { "level": 2 },
1108
+ "content": [{ "type": "text", "text": "Hello" }]
1109
+ },
1110
+ {
1111
+ "type": "paragraph",
1112
+ "content": [
1113
+ { "type": "text", "text": "This is " },
1114
+ { "type": "text", "text": "bold", "marks": [{ "type": "bold" }] },
1115
+ { "type": "text", "text": " content." }
1116
+ ]
1117
+ }
1118
+ ]
1119
+ }
1120
+ ```
1121
+
1122
+ ## Node Types
1123
+
1124
+ | Type | Description |
1125
+ |------|-------------|
1126
+ | `paragraph` | Text paragraph |
1127
+ | `heading` | Heading (attrs: `level: 2 \| 3`) |
1128
+ | `blockquote` | Block quote |
1129
+ | `bulletList` | Bullet list |
1130
+ | `orderedList` | Numbered list |
1131
+ | `listItem` | List item |
1132
+ | `codeBlock` | Code block |
1133
+ | `horizontalRule` | Horizontal line |
1134
+ | `table`, `tableRow`, `tableCell`, `tableHeader` | Table elements |
1135
+ | `figure` | Image with caption (attrs: `src`, `alt`, `caption`, `data-media-id`) |
1136
+ | `video` | Video embed (attrs: `src`, `poster`, `data-media-id`) |
1137
+ | `inlineBlock` | Custom inline block (attrs: `blockType`, `blockData`, `blockId`) |
1138
+ | `hardBreak` | Line break |
1139
+
1140
+ ## Mark Types
1141
+
1142
+ | Mark | Description |
1143
+ |------|-------------|
1144
+ | `bold` | Bold text |
1145
+ | `italic` | Italic text |
1146
+ | `underline` | Underlined text |
1147
+ | `strike` | Strikethrough |
1148
+ | `code` | Inline code |
1149
+ | `link` | Hyperlink (attrs: `href`, `target`, `title`, `aria-label`) |
1150
+ | `highlight` | Highlighted text |
1151
+
1152
+ ## Rendering on Frontend
1153
+
1154
+ Use the built-in `<StructuredContent>` component:
1155
+
1156
+ ```svelte
1157
+ <StructuredContent doc={entry.body} class="prose" />
1158
+ ```
1159
+
1160
+ Override specific node types with snippets:
1161
+
1162
+ ```svelte
1163
+ <StructuredContent doc={entry.body}>
1164
+ {#snippet inlineBlock(blockType, blockData, blockId)}
1165
+ {#if blockType === 'callout'}
1166
+ <aside class="callout">{blockData.text}</aside>
1167
+ {/if}
1168
+ {/snippet}
1169
+ </StructuredContent>
1170
+ ```
1171
+
1172
+ For RSS feeds or emails, convert to HTML string:
1173
+
1174
+ ```typescript
1175
+ import { structuredToHtml } from 'includio-cms/sveltekit';
1176
+ const html = structuredToHtml(entry.body);
1177
+ ```
1178
+
1179
+ See [Frontend Rendering](/docs/frontend) for all component details and snippet overrides.
1180
+
1181
+ > **Media in Content:** Images and videos inserted in the editor are stored as `figure` and `video` nodes with a `data-media-id` attribute. The CMS resolves these to full media data (styles, blur URLs) when entries are fetched via the API.
1182
+
1183
+
1184
+ ---
1185
+
1186
+ # Number Field
1187
+
1188
+ Numeric input with optional min, max, and step constraints.
1189
+
1190
+ ## Basic Usage
1191
+
1192
+ ```typescript
1193
+ {
1194
+ type: 'number',
1195
+ slug: 'price',
1196
+ label: 'Price',
1197
+ required: true
1198
+ }
1199
+ ```
1200
+
1201
+ ## With Options
1202
+
1203
+ ```typescript
1204
+ {
1205
+ type: 'number',
1206
+ slug: 'quantity',
1207
+ label: 'Quantity',
1208
+ min: 0,
1209
+ max: 1000,
1210
+ step: 1,
1211
+ defaultValue: 1
1212
+ }
1213
+ ```
1214
+
1215
+ ## Properties
1216
+
1217
+ | Property | Type | Default | Description |
1218
+ |----------|------|---------|-------------|
1219
+ | `min` | `number` | — | Minimum allowed value |
1220
+ | `max` | `number` | — | Maximum allowed value |
1221
+ | `step` | `number` | — | Increment step (e.g., 0.01 for currency) |
1222
+ | `defaultValue` | `number` | — | Default numeric value |
1223
+
1224
+ ## Output
1225
+
1226
+ Returns a `number`.
1227
+
1228
+ ```typescript
1229
+ { price: 29.99, quantity: 5 }
1230
+ ```
1231
+
1232
+ > **Decimals:** Set `step: 0.01` for currency values, or `step: 1` for integers only.
1233
+
1234
+
1235
+ ---
1236
+
1237
+ # Boolean Field
1238
+
1239
+ Toggle switch for true/false values.
1240
+
1241
+ ## Basic Usage
1242
+
1243
+ ```typescript
1244
+ {
1245
+ type: 'boolean',
1246
+ slug: 'featured',
1247
+ label: 'Featured',
1248
+ defaultValue: false
1249
+ }
1250
+ ```
1251
+
1252
+ ## With Options
1253
+
1254
+ ```typescript
1255
+ {
1256
+ type: 'boolean',
1257
+ slug: 'showInNav',
1258
+ label: 'Show in Navigation',
1259
+ description: 'Display this page in the main navigation',
1260
+ defaultValue: true
1261
+ }
1262
+ ```
1263
+
1264
+ ## Properties
1265
+
1266
+ | Property | Type | Default | Description |
1267
+ |----------|------|---------|-------------|
1268
+ | `defaultValue` | `boolean` | — | Default toggle state |
1269
+
1270
+ ## Output
1271
+
1272
+ Returns a `boolean`.
1273
+
1274
+ ```typescript
1275
+ { featured: true, showInNav: false }
1276
+ ```
1277
+
1278
+ > **Common Uses:** Use for feature flags, visibility toggles, or any binary state like "Show in navigation" or "Allow comments".
1279
+
1280
+
1281
+ ---
1282
+
1283
+ # Date Field
1284
+
1285
+ Date picker that stores values in ISO format (YYYY-MM-DD).
1286
+
1287
+ ## Basic Usage
1288
+
1289
+ ```typescript
1290
+ {
1291
+ type: 'date',
1292
+ slug: 'publishedAt',
1293
+ label: 'Published Date'
1294
+ }
1295
+ ```
1296
+
1297
+ ## With Options
1298
+
1299
+ ```typescript
1300
+ {
1301
+ type: 'date',
1302
+ slug: 'eventDate',
1303
+ label: 'Event Date',
1304
+ required: true,
1305
+ minDate: '2024-01-01',
1306
+ maxDate: '2025-12-31'
1307
+ }
1308
+ ```
1309
+
1310
+ ## Properties
1311
+
1312
+ | Property | Type | Default | Description |
1313
+ |----------|------|---------|-------------|
1314
+ | `minDate` | `string` | — | Earliest selectable date (ISO) |
1315
+ | `maxDate` | `string` | — | Latest selectable date (ISO) |
1316
+ | `defaultValue` | `string` | — | Default date (ISO) |
1317
+
1318
+ ## Output
1319
+
1320
+ Returns an ISO date `string`.
1321
+
1322
+ ```typescript
1323
+ { publishedAt: "2024-06-15" }
1324
+ ```
1325
+
1326
+ > **Date vs DateTime:** Use Date for day-level precision (publish dates, birthdays). Use [DateTime](/docs/fields/datetime) when you need time-of-day.
1327
+
1328
+
1329
+ ---
1330
+
1331
+ # DateTime Field
1332
+
1333
+ Date and time picker that stores values in ISO format.
1334
+
1335
+ ## Basic Usage
1336
+
1337
+ ```typescript
1338
+ {
1339
+ type: 'datetime',
1340
+ slug: 'scheduledAt',
1341
+ label: 'Scheduled At'
1342
+ }
1343
+ ```
1344
+
1345
+ ## With Options
1346
+
1347
+ ```typescript
1348
+ {
1349
+ type: 'datetime',
1350
+ slug: 'startsAt',
1351
+ label: 'Start Time',
1352
+ required: true,
1353
+ minDate: '2024-01-01T00:00:00',
1354
+ maxDate: '2025-12-31T23:59:59'
1355
+ }
1356
+ ```
1357
+
1358
+ ## Properties
1359
+
1360
+ | Property | Type | Default | Description |
1361
+ |----------|------|---------|-------------|
1362
+ | `minDate` | `string` | — | Earliest selectable date/time (ISO) |
1363
+ | `maxDate` | `string` | — | Latest selectable date/time (ISO) |
1364
+ | `defaultValue` | `string` | — | Default date/time (ISO) |
1365
+
1366
+ ## Output
1367
+
1368
+ Returns an ISO datetime `string`.
1369
+
1370
+ ```typescript
1371
+ { scheduledAt: "2024-06-15T14:30:00.000Z" }
1372
+ ```
1373
+
1374
+ > **Scheduling:** Use DateTime for scheduled publishing, event start/end times, or any timestamp that needs time-of-day precision.
1375
+
1376
+
1377
+ ---
1378
+
1379
+ # File Field
1380
+
1381
+ File upload with MIME type filtering and size limits.
1382
+
1383
+ ## Basic Usage
1384
+
1385
+ ```typescript
1386
+ {
1387
+ type: 'file',
1388
+ slug: 'document',
1389
+ label: 'Document',
1390
+ accept: 'application/pdf',
1391
+ maxSizeMB: 10
1392
+ }
1393
+ ```
1394
+
1395
+ ## With Options (Multiple)
1396
+
1397
+ ```typescript
1398
+ {
1399
+ type: 'file',
1400
+ slug: 'attachments',
1401
+ label: 'Attachments',
1402
+ multiple: true,
1403
+ accept: 'application/pdf,application/zip,text/csv',
1404
+ maxSizeMB: 25
1405
+ }
1406
+ ```
1407
+
1408
+ ## Properties
1409
+
1410
+ | Property | Type | Default | Description |
1411
+ |----------|------|---------|-------------|
1412
+ | `accept` | `string` | — | Allowed MIME types (comma-separated) |
1413
+ | `maxSizeMB` | `number` | — | Maximum file size in MB |
1414
+ | `multiple` | `boolean` | `false` | Allow multiple file uploads |
1415
+ | `defaultValue` | `string \| string[]` | — | Default file reference(s) |
1416
+
1417
+ ## Output
1418
+
1419
+ Returns a `string` (media file ID) or `string[]` when `multiple: true`.
1420
+
1421
+ ```typescript
1422
+ // Single
1423
+ { document: "file_abc123" }
1424
+
1425
+ // Multiple
1426
+ { attachments: ["file_abc123", "file_def456"] }
1427
+ ```
1428
+
1429
+ > **MIME Types:** Use standard MIME types in `accept`: `application/pdf`, `image/*`, `video/mp4`, etc. Separate multiple types with commas.
1430
+
1431
+
1432
+ ---
1433
+
1434
+ # Media Field
1435
+
1436
+ Upload and manage images, videos, and other media. Supports image transforms via Sharp, video accessibility (transcripts, audio descriptions), and blur data URLs.
1437
+
1438
+ ## Basic Usage
1439
+
1440
+ ```typescript
1441
+ {
1442
+ type: 'media',
1443
+ slug: 'cover',
1444
+ label: 'Cover Image'
1445
+ }
1446
+ ```
1447
+
1448
+ ## With Image Styles
1449
+
1450
+ Generate responsive variants on upload:
1451
+
1452
+ ```typescript
1453
+ {
1454
+ type: 'media',
1455
+ slug: 'hero',
1456
+ label: 'Hero Image',
1457
+ accept: 'image/*',
1458
+ maxSizeMB: 10,
1459
+ styles: [
1460
+ { name: 'thumbnail', width: 200, height: 200, crop: true },
1461
+ { name: 'medium', width: 800, format: 'webp', quality: 80 },
1462
+ { name: 'large', width: 1600, format: 'webp' },
1463
+ { name: 'og', width: 1200, height: 630, crop: true, format: 'jpeg' }
1464
+ ]
1465
+ }
1466
+ ```
1467
+
1468
+ ## Video Usage
1469
+
1470
+ ```typescript
1471
+ {
1472
+ type: 'media',
1473
+ slug: 'video',
1474
+ label: 'Video',
1475
+ accept: 'video/*',
1476
+ maxSizeMB: 100
1477
+ }
1478
+ ```
1479
+
1480
+ ## Properties
1481
+
1482
+ | Property | Type | Default | Description |
1483
+ |----------|------|---------|-------------|
1484
+ | `accept` | `string` | — | MIME filter: `'image/*'`, `'video/*'`, `'image/*,video/*'` |
1485
+ | `maxSizeMB` | `number` | — | Max file size in MB |
1486
+ | `multiple` | `boolean` | `false` | Allow multiple files |
1487
+ | `styles` | `ImageFieldStyle[]` | — | Image transform definitions (images only) |
1488
+
1489
+ ## ImageFieldStyle
1490
+
1491
+ | Property | Type | Description |
1492
+ |----------|------|-------------|
1493
+ | `name` | `string` | Style identifier (key in output) |
1494
+ | `width` | `number` | Target width in pixels |
1495
+ | `height` | `number` | Target height in pixels |
1496
+ | `format` | `keyof FormatEnum` | Output format: `webp`, `avif`, `jpeg`, `png` |
1497
+ | `crop` | `boolean` | Crop to exact dimensions (vs resize to fit) |
1498
+ | `quality` | `number` | Compression quality (1–100) |
1499
+ | `media` | `string` | Media query for responsive `<picture>` usage |
1500
+ | `srcset` | `number[]` | Widths for srcset generation |
1501
+ | `sizes` | `string` | Sizes attribute for responsive images |
1502
+
1503
+ ## Output
1504
+
1505
+ Returns `MediaFieldData` — either `ImageFieldData` or `VideoFieldData` depending on file type.
1506
+
1507
+ ### Image Output
1508
+
1509
+ ```typescript
1510
+ interface ImageFieldData {
1511
+ data: MediaFile;
1512
+ styles: Record<string, ImageStyle>;
1513
+ blurDataUrl?: string | null;
1514
+ }
1515
+
1516
+ // Example
1517
+ {
1518
+ data: { id: "abc", url: "/uploads/hero.jpg", type: "image", width: 2400, height: 1600, alt: "..." },
1519
+ styles: {
1520
+ thumbnail: { url: "/uploads/hero-thumb.webp", mimeType: "image/webp" },
1521
+ medium: { url: "/uploads/hero-md.webp", mimeType: "image/webp", srcset: "...", sizes: "..." }
1522
+ },
1523
+ blurDataUrl: "data:image/png;base64,..."
1524
+ }
1525
+ ```
1526
+
1527
+ ### Video Output
1528
+
1529
+ ```typescript
1530
+ interface VideoFieldData {
1531
+ data: MediaFile;
1532
+ styles: Record<string, VideoStyle>; // Transcoded variants
1533
+ transcript: MediaFile | null;
1534
+ audioDescription: MediaFile | null;
1535
+ }
1536
+
1537
+ interface VideoStyle {
1538
+ id: string;
1539
+ mediaFileId: string;
1540
+ name: string;
1541
+ url: string;
1542
+ width: number | null;
1543
+ height: number | null;
1544
+ format: string;
1545
+ codec: string | null;
1546
+ fileSize: number | null;
1547
+ mimeType: string;
1548
+ status: 'pending' | 'processing' | 'done' | 'failed';
1549
+ error: string | null;
1550
+ }
1551
+
1552
+ // Example
1553
+ {
1554
+ data: { id: "xyz", url: "/uploads/intro.mp4", type: "video", duration: 120, posterUrl: "/uploads/intro-poster.jpg" },
1555
+ styles: {
1556
+ "mp4-1080": { url: "/uploads/intro-mp4-1080.mp4", format: "mp4", codec: "h264", mimeType: "video/mp4", status: "done", ... },
1557
+ "webm-1080": { url: "/uploads/intro-webm-1080.webm", format: "webm", codec: "vp9", mimeType: "video/webm", status: "done", ... }
1558
+ },
1559
+ transcript: { id: "t1", url: "/uploads/intro-transcript.vtt", ... },
1560
+ audioDescription: null
1561
+ }
1562
+ ```
1563
+
1564
+ ## MediaFile
1565
+
1566
+ The full `MediaFile` interface returned in media field output:
1567
+
1568
+ ```typescript
1569
+ interface MediaFile {
1570
+ id: string;
1571
+ name: string;
1572
+ url: string;
1573
+ type: 'image' | 'video' | 'audio' | 'pdf' | 'other';
1574
+ size: number;
1575
+ width: number | null;
1576
+ height: number | null;
1577
+ alt: string | null;
1578
+ tags: MediaTag[];
1579
+ createdAt: Date;
1580
+ mimeType: string | null;
1581
+ blurDataUrl: string | null;
1582
+ focalX: number | null;
1583
+ focalY: number | null;
1584
+ // Video-specific
1585
+ thumbnailUrl: string | null;
1586
+ duration: number | null;
1587
+ posterUrl: string | null;
1588
+ transcriptFileId: string | null;
1589
+ audioDescriptionFileId: string | null;
1590
+ }
1591
+ ```
1592
+
1593
+ > **Performance:** Define styles for all breakpoints you need. Variants are generated on upload, so your frontend serves optimized images without runtime processing. Use `blurDataUrl` for progressive loading placeholders.
1594
+
1595
+ ## VideoFieldStyle
1596
+
1597
+ When defining custom video styles in fields (advanced usage):
1598
+
1599
+ | Property | Type | Required | Description |
1600
+ |----------|------|----------|-------------|
1601
+ | `name` | `string` | Yes | Style identifier |
1602
+ | `format` | `'mp4' \| 'webm'` | Yes | Output format |
1603
+ | `codec` | `'h264' \| 'vp9'` | No | Video codec |
1604
+ | `maxWidth` | `number` | No | Max width in pixels |
1605
+ | `maxHeight` | `number` | No | Max height in pixels |
1606
+ | `crf` | `number` | No | Compression quality (lower = better) |
1607
+ | `audioBitrate` | `string` | No | Audio bitrate (e.g. `'128k'`) |
1608
+
1609
+ > **Video Accessibility:** For videos, the admin panel allows attaching transcript and audio description files. These are essential for WCAG compliance and are returned as separate `MediaFile` objects in the API output.
1610
+
1611
+ > **Video Transcoding:** Videos are auto-transcoded to mp4/webm in the background. The `&lt;Video&gt;` component renders transcoded sources automatically. See [Video Transcoding](/docs/video-transcoding).
1612
+
1613
+
1614
+ ---
1615
+
1616
+ # Select Field
1617
+
1618
+ Dropdown selection from predefined options. Supports single or multiple selection.
1619
+
1620
+ ## Basic Usage
1621
+
1622
+ ```typescript
1623
+ {
1624
+ type: 'select',
1625
+ slug: 'category',
1626
+ label: 'Category',
1627
+ options: [
1628
+ { label: 'Technology', value: 'tech' },
1629
+ { label: 'Design', value: 'design' },
1630
+ { label: 'Business', value: 'business' }
1631
+ ]
1632
+ }
1633
+ ```
1634
+
1635
+ ## With Multiple Selection
1636
+
1637
+ ```typescript
1638
+ {
1639
+ type: 'select',
1640
+ slug: 'tags',
1641
+ label: 'Tags',
1642
+ multiple: true,
1643
+ options: [
1644
+ { label: 'Featured', value: 'featured' },
1645
+ { label: 'Popular', value: 'popular' },
1646
+ { label: 'New', value: 'new' }
1647
+ ],
1648
+ defaultValue: ['new']
1649
+ }
1650
+ ```
1651
+
1652
+ ## Properties
1653
+
1654
+ | Property | Type | Default | Description |
1655
+ |----------|------|---------|-------------|
1656
+ | `options` | `{ label: Localized; value: string }[]` | — | Available choices (labels support per-language values) |
1657
+ | `multiple` | `boolean` | `false` | Allow multiple selections |
1658
+ | `defaultValue` | `string \| string[]` | — | Default selection(s) |
1659
+
1660
+ ## Output
1661
+
1662
+ Returns a `string` or `string[]` when `multiple: true`.
1663
+
1664
+ ```typescript
1665
+ // Single
1666
+ { category: "tech" }
1667
+
1668
+ // Multiple
1669
+ { tags: ["featured", "new"] }
1670
+ ```
1671
+
1672
+ > **Select vs Radio:** Use Select for 4+ options or when multiple selection is needed. Use [Radio](/docs/fields/radio) for 2-3 mutually exclusive choices where all options should be visible.
1673
+
1674
+
1675
+ ---
1676
+
1677
+ # Radio Field
1678
+
1679
+ Radio button group for single selection. All options are visible at once.
1680
+
1681
+ ## Basic Usage
1682
+
1683
+ ```typescript
1684
+ {
1685
+ type: 'radio',
1686
+ slug: 'layout',
1687
+ label: 'Layout',
1688
+ options: [
1689
+ { label: 'Full Width', value: 'full' },
1690
+ { label: 'Sidebar', value: 'sidebar' },
1691
+ { label: 'Centered', value: 'centered' }
1692
+ ]
1693
+ }
1694
+ ```
1695
+
1696
+ ## With Default
1697
+
1698
+ ```typescript
1699
+ {
1700
+ type: 'radio',
1701
+ slug: 'priority',
1702
+ label: 'Priority',
1703
+ options: [
1704
+ { label: 'Low', value: 'low' },
1705
+ { label: 'Medium', value: 'medium' },
1706
+ { label: 'High', value: 'high' }
1707
+ ],
1708
+ defaultValue: 'medium'
1709
+ }
1710
+ ```
1711
+
1712
+ ## Properties
1713
+
1714
+ | Property | Type | Default | Description |
1715
+ |----------|------|---------|-------------|
1716
+ | `options` | `{ label: Localized; value: string }[]` | — | Available choices (labels support per-language values) |
1717
+ | `defaultValue` | `string` | — | Default selected value |
1718
+
1719
+ ## Output
1720
+
1721
+ Returns a `string`.
1722
+
1723
+ ```typescript
1724
+ { layout: "sidebar", priority: "medium" }
1725
+ ```
1726
+
1727
+ > **Radio vs Select:** Radio shows all options at once — best for 2-3 choices. For longer lists, use [Select](/docs/fields/select) to save space.
1728
+
1729
+
1730
+ ---
1731
+
1732
+ # Checkboxes Field
1733
+
1734
+ Checkbox group for multiple selection from predefined options.
1735
+
1736
+ ## Basic Usage
1737
+
1738
+ ```typescript
1739
+ {
1740
+ type: 'checkboxes',
1741
+ slug: 'features',
1742
+ label: 'Features',
1743
+ options: [
1744
+ { label: 'Responsive', value: 'responsive' },
1745
+ { label: 'Dark Mode', value: 'dark-mode' },
1746
+ { label: 'Animations', value: 'animations' },
1747
+ { label: 'SEO Optimized', value: 'seo' }
1748
+ ]
1749
+ }
1750
+ ```
1751
+
1752
+ ## With Default
1753
+
1754
+ ```typescript
1755
+ {
1756
+ type: 'checkboxes',
1757
+ slug: 'permissions',
1758
+ label: 'Permissions',
1759
+ options: [
1760
+ { label: 'Read', value: 'read' },
1761
+ { label: 'Write', value: 'write' },
1762
+ { label: 'Delete', value: 'delete' }
1763
+ ],
1764
+ defaultValue: ['read']
1765
+ }
1766
+ ```
1767
+
1768
+ ## Properties
1769
+
1770
+ | Property | Type | Default | Description |
1771
+ |----------|------|---------|-------------|
1772
+ | `options` | `{ label: Localized; value: string }[]` | — | Available choices (labels support per-language values) |
1773
+ | `defaultValue` | `string \| string[]` | — | Default checked values |
1774
+
1775
+ ## Output
1776
+
1777
+ Returns a `string[]` of selected values.
1778
+
1779
+ ```typescript
1780
+ { features: ["responsive", "dark-mode", "seo"] }
1781
+ ```
1782
+
1783
+ > **Checkboxes vs Multi-Select:** Checkboxes show all options visually — good for small sets. For many options, use [Select](/docs/fields/select) with `multiple: true`.
1784
+
1785
+
1786
+ ---
1787
+
1788
+ # Relation Field
1789
+
1790
+ Reference to entries from another collection. Creates relationships between content types.
1791
+
1792
+ ## Basic Usage
1793
+
1794
+ ```typescript
1795
+ {
1796
+ type: 'relation',
1797
+ slug: 'author',
1798
+ label: 'Author',
1799
+ collection: 'team',
1800
+ displayField: 'name'
1801
+ }
1802
+ ```
1803
+
1804
+ ## Multiple Relations
1805
+
1806
+ ```typescript
1807
+ {
1808
+ type: 'relation',
1809
+ slug: 'relatedPosts',
1810
+ label: 'Related Posts',
1811
+ collection: 'posts',
1812
+ multiple: true,
1813
+ displayField: 'title'
1814
+ }
1815
+ ```
1816
+
1817
+ ## Properties
1818
+
1819
+ | Property | Type | Default | Description |
1820
+ |----------|------|---------|-------------|
1821
+ | `collection` | `string` | — | Target collection slug (required) |
1822
+ | `multiple` | `boolean` | `false` | Allow multiple relations |
1823
+ | `displayField` | `string` | — | Field slug shown as label in the picker |
1824
+ | `defaultValue` | `string \| string[]` | — | Default entry ID(s) |
1825
+
1826
+ ## Output
1827
+
1828
+ Returns a `string` (entry ID) or `string[]` when `multiple: true`.
1829
+
1830
+ ```typescript
1831
+ // Single
1832
+ { author: "entry_abc123" }
1833
+
1834
+ // Multiple
1835
+ { relatedPosts: ["entry_abc123", "entry_def456", "entry_ghi789"] }
1836
+ ```
1837
+
1838
+ > **Display Field:** Always set `displayField` to a text field slug so the admin picker shows readable labels instead of entry IDs.
1839
+
1840
+
1841
+ ---
1842
+
1843
+ # Object Field
1844
+
1845
+ Nested group of fields rendered as a collapsible section in the admin.
1846
+
1847
+ ## Basic Usage
1848
+
1849
+ ```typescript
1850
+ {
1851
+ type: 'object',
1852
+ slug: 'address',
1853
+ label: 'Address',
1854
+ fields: [
1855
+ { type: 'text', slug: 'street', label: 'Street' },
1856
+ { type: 'text', slug: 'city', label: 'City' },
1857
+ { type: 'text', slug: 'zip', label: 'ZIP Code' },
1858
+ { type: 'text', slug: 'country', label: 'Country' }
1859
+ ]
1860
+ }
1861
+ ```
1862
+
1863
+ ## With defineObject Helper
1864
+
1865
+ ```typescript
1866
+ import { defineObject } from 'includio-cms/sveltekit';
1867
+
1868
+ const cta = defineObject({
1869
+ slug: 'cta',
1870
+ label: 'Call to Action',
1871
+ accordionLabelField: 'label',
1872
+ fields: [
1873
+ { type: 'text', slug: 'label', required: true },
1874
+ { type: 'url', slug: 'link' },
1875
+ { type: 'select', slug: 'variant', options: [
1876
+ { label: 'Primary', value: 'primary' },
1877
+ { label: 'Secondary', value: 'secondary' }
1878
+ ]}
1879
+ ]
1880
+ });
1881
+ ```
1882
+
1883
+ ## Properties
1884
+
1885
+ | Property | Type | Default | Description |
1886
+ |----------|------|---------|-------------|
1887
+ | `fields` | `Field[]` | — | Nested field definitions (required) |
1888
+ | `accordionLabelField` | `string` | — | Field slug for the accordion header label |
1889
+ | `defaultValue` | `ObjectFieldData` | — | Default nested values |
1890
+
1891
+ ## Output
1892
+
1893
+ Returns `ObjectFieldData`:
1894
+
1895
+ ```typescript
1896
+ interface ObjectFieldData {
1897
+ slug?: string; // Object type slug (used in arrays)
1898
+ data: Record<string, unknown>;
1899
+ }
1900
+
1901
+ // Example
1902
+ {
1903
+ address: {
1904
+ data: {
1905
+ street: "123 Main St",
1906
+ city: "Portland",
1907
+ zip: "97201",
1908
+ country: "US"
1909
+ }
1910
+ }
1911
+ }
1912
+ ```
1913
+
1914
+ > **Nesting:** Objects can contain any field type, including other objects and arrays. Use them to group related fields logically.
1915
+
1916
+
1917
+ ---
1918
+
1919
+ # Array Field
1920
+
1921
+ Simple typed arrays of text, numbers, or URLs. Items can be reordered via drag-and-drop.
1922
+
1923
+ > **Looking for repeatable objects?:** For arrays of complex objects (page builders, block-based content), use the [Blocks](/docs/fields/blocks) field instead.
1924
+
1925
+ ## Basic Usage
1926
+
1927
+ ```typescript
1928
+ {
1929
+ type: 'array',
1930
+ slug: 'tags',
1931
+ label: 'Tags',
1932
+ of: 'text'
1933
+ }
1934
+ ```
1935
+
1936
+ ## Examples
1937
+
1938
+ ### Text Array
1939
+
1940
+ ```typescript
1941
+ {
1942
+ type: 'array',
1943
+ slug: 'tags',
1944
+ label: 'Tags',
1945
+ of: 'text',
1946
+ minItems: 1,
1947
+ maxItems: 10
1948
+ }
1949
+ // Output: ["svelte", "cms", "typescript"]
1950
+ ```
1951
+
1952
+ ### Number Array
1953
+
1954
+ ```typescript
1955
+ {
1956
+ type: 'array',
1957
+ slug: 'scores',
1958
+ label: 'Scores',
1959
+ of: 'number',
1960
+ minItems: 3,
1961
+ maxItems: 3
1962
+ }
1963
+ // Output: [95, 87, 72]
1964
+ ```
1965
+
1966
+ ### URL Array
1967
+
1968
+ ```typescript
1969
+ {
1970
+ type: 'array',
1971
+ slug: 'links',
1972
+ label: 'Links',
1973
+ of: 'url'
1974
+ }
1975
+ // Output: [{ url: { en: "https://..." }, text: { en: "Link" } }, ...]
1976
+ ```
1977
+
1978
+ ## Properties
1979
+
1980
+ | Property | Type | Default | Description |
1981
+ |----------|------|---------|-------------|
1982
+ | `of` | `'text' \| 'number' \| 'url'` | — | Item type (required) |
1983
+ | `minItems` | `number` | — | Minimum items required |
1984
+ | `maxItems` | `number` | — | Maximum items allowed |
1985
+ | `defaultValue` | `(string \| number \| UrlFieldData)[]` | — | Default items |
1986
+
1987
+ ## Output
1988
+
1989
+ Depends on the `of` type:
1990
+
1991
+ | `of` | Output Type |
1992
+ |------|------------|
1993
+ | `'text'` | `string[]` |
1994
+ | `'number'` | `number[]` |
1995
+ | `'url'` | `UrlFieldData[]` |
1996
+
1997
+
1998
+ ---
1999
+
2000
+ # Blocks Field
2001
+
2002
+ Repeatable list of typed objects. Each item is an instance of one of the defined block types. Items can be reordered via drag-and-drop. Ideal for page builders and flexible content sections.
2003
+
2004
+ ## Basic Usage
2005
+
2006
+ ```typescript
2007
+ {
2008
+ type: 'blocks',
2009
+ slug: 'slides',
2010
+ label: 'Slides',
2011
+ minItems: 1,
2012
+ maxItems: 10,
2013
+ of: [
2014
+ {
2015
+ type: 'object',
2016
+ slug: 'slide',
2017
+ label: 'Slide',
2018
+ accordionLabelField: 'title',
2019
+ fields: [
2020
+ { type: 'text', slug: 'title', required: true },
2021
+ { type: 'content', slug: 'description' },
2022
+ { type: 'media', slug: 'image', accept: 'image/*' }
2023
+ ]
2024
+ }
2025
+ ]
2026
+ }
2027
+ ```
2028
+
2029
+ ## Multiple Block Types
2030
+
2031
+ Create a flexible page builder with multiple block types:
2032
+
2033
+ ```typescript
2034
+ {
2035
+ type: 'blocks',
2036
+ slug: 'content',
2037
+ label: 'Page Content',
2038
+ of: [
2039
+ {
2040
+ type: 'object',
2041
+ slug: 'text-block',
2042
+ label: 'Text Block',
2043
+ fields: [{ type: 'content', slug: 'content' }]
2044
+ },
2045
+ {
2046
+ type: 'object',
2047
+ slug: 'image-block',
2048
+ label: 'Image Block',
2049
+ fields: [
2050
+ { type: 'media', slug: 'image', accept: 'image/*' },
2051
+ { type: 'text', slug: 'caption' }
2052
+ ]
2053
+ },
2054
+ {
2055
+ type: 'object',
2056
+ slug: 'cta-block',
2057
+ label: 'CTA Block',
2058
+ fields: [
2059
+ { type: 'text', slug: 'label', required: true },
2060
+ { type: 'url', slug: 'link' }
2061
+ ]
2062
+ }
2063
+ ]
2064
+ }
2065
+ ```
2066
+
2067
+ ## Properties
2068
+
2069
+ | Property | Type | Default | Description |
2070
+ |----------|------|---------|-------------|
2071
+ | `of` | `ObjectField[]` | — | Allowed block types (required) |
2072
+ | `minItems` | `number` | — | Minimum items required |
2073
+ | `maxItems` | `number` | — | Maximum items allowed |
2074
+ | `defaultValue` | `ObjectFieldData[]` | — | Default items |
2075
+ | `displayMode` | `'simple' \| 'blocks'` | `'simple'` | Admin UI display mode |
2076
+
2077
+ ### Display Modes
2078
+
2079
+ - **`simple`** — accordion-style list (default). Good for simple repeating groups.
2080
+ - **`blocks`** — block picker UI with type selection dialog. Better for page builder with many block types.
2081
+
2082
+ ## Output
2083
+
2084
+ Returns `ObjectFieldData[]`:
2085
+
2086
+ ```typescript
2087
+ [
2088
+ { _id: "abc", _slug: "text-block", content: { type: "doc", content: [...] } },
2089
+ { _id: "def", _slug: "image-block", image: { data: { ... }, styles: { ... } }, caption: "Photo" },
2090
+ { _id: "ghi", _slug: "text-block", content: { type: "doc", content: [...] } }
2091
+ ]
2092
+ ```
2093
+
2094
+ Each item has `_id` (unique instance ID) and `_slug` (block type slug) plus the block's field data.
2095
+
2096
+ ## Fixed Length
2097
+
2098
+ When `minItems === maxItems`, the field enters **fixed-length mode**: items are pre-populated on mount, and add/duplicate/delete controls are hidden. Only reordering is allowed.
2099
+
2100
+ ```typescript
2101
+ {
2102
+ type: 'blocks',
2103
+ slug: 'features',
2104
+ minItems: 3,
2105
+ maxItems: 3,
2106
+ of: [
2107
+ {
2108
+ type: 'object',
2109
+ slug: 'feature',
2110
+ label: 'Feature',
2111
+ fields: [
2112
+ { type: 'text', slug: 'title', required: true },
2113
+ { type: 'media', slug: 'icon', accept: 'image/*' }
2114
+ ]
2115
+ }
2116
+ ]
2117
+ }
2118
+ ```
2119
+
2120
+ For multi-type blocks, use `defaultValue` to control which types are pre-populated:
2121
+
2122
+ ```typescript
2123
+ {
2124
+ type: 'blocks',
2125
+ slug: 'hero',
2126
+ minItems: 2,
2127
+ maxItems: 2,
2128
+ defaultValue: [
2129
+ { _slug: 'heading' },
2130
+ { _slug: 'cta' }
2131
+ ],
2132
+ of: [
2133
+ { type: 'object', slug: 'heading', fields: [...] },
2134
+ { type: 'object', slug: 'cta', fields: [...] }
2135
+ ]
2136
+ }
2137
+ ```
2138
+
2139
+ > **Page Builder:** Use blocks with multiple types and `displayMode: 'blocks'` to create a flexible page builder. The `_slug` in each item tells your frontend which component to render.
2140
+
2141
+
2142
+ ---
2143
+
2144
+ # Slug Field
2145
+
2146
+ URL-friendly slug with optional pattern support. Automatically transforms input to lowercase with hyphens.
2147
+
2148
+ ## Basic Usage
2149
+
2150
+ ```typescript
2151
+ {
2152
+ type: 'slug',
2153
+ slug: 'slug',
2154
+ label: 'URL Slug'
2155
+ }
2156
+ ```
2157
+
2158
+ ## With Pattern
2159
+
2160
+ ```typescript
2161
+ {
2162
+ type: 'slug',
2163
+ slug: 'url',
2164
+ label: 'URL Path',
2165
+ pattern: '/blog/{slug}'
2166
+ }
2167
+ ```
2168
+
2169
+ ## Properties
2170
+
2171
+ | Property | Type | Default | Description |
2172
+ |----------|------|---------|-------------|
2173
+ | `pattern` | `string` | — | URL pattern with `{slug}` placeholder |
2174
+ | `defaultValue` | `string` | — | Default slug value |
2175
+
2176
+ ## Output
2177
+
2178
+ Returns a `string`. Values are automatically slugified: lowercase, hyphens for spaces, no special characters.
2179
+
2180
+ ```typescript
2181
+ // Input: "My Blog Post!"
2182
+ // Output:
2183
+ { slug: "my-blog-post" }
2184
+
2185
+ // With pattern '/blog/{slug}':
2186
+ { url: "/blog/my-blog-post" }
2187
+ ```
2188
+
2189
+ > **URL Patterns:** Use `pattern` to generate full URL paths. The `{"{slug}"}` placeholder is replaced with the slugified value.
2190
+
2191
+
2192
+ ---
2193
+
2194
+ # SEO Field
2195
+
2196
+ Complete SEO metadata group. Renders a dedicated section in the admin with all common SEO fields.
2197
+
2198
+ ## Basic Usage
2199
+
2200
+ ```typescript
2201
+ {
2202
+ type: 'seo',
2203
+ slug: 'seo',
2204
+ label: 'SEO'
2205
+ }
2206
+ ```
2207
+
2208
+ No additional options needed — the SEO field includes all sub-fields automatically.
2209
+
2210
+ ## Included Sub-fields
2211
+
2212
+ | Sub-field | Type | Description |
2213
+ |-----------|------|-------------|
2214
+ | `slug` | `string` | URL slug for the page |
2215
+ | `title` | `string` | Page title / OG title |
2216
+ | `description` | `string` | Meta description |
2217
+ | `canonicalUrl` | `string` | Canonical URL override |
2218
+ | `ogImage` | `string` | Open Graph image |
2219
+ | `keywords` | `string` | Meta keywords |
2220
+ | `customCode` | `string` | Custom code injection (`<head>`) |
2221
+
2222
+ ## Output
2223
+
2224
+ Returns `SeoFieldData`:
2225
+
2226
+ ```typescript
2227
+ interface SeoFieldData {
2228
+ slug: string;
2229
+ title: string;
2230
+ description?: string;
2231
+ canonicalUrl?: string;
2232
+ ogImage?: string;
2233
+ keywords?: string;
2234
+ customCode?: string;
2235
+ }
2236
+
2237
+ // Example
2238
+ {
2239
+ seo: {
2240
+ slug: "my-blog-post",
2241
+ title: "My Blog Post | My Site",
2242
+ description: "A great article about...",
2243
+ ogImage: "img_abc123",
2244
+ keywords: "blog, tech, svelte"
2245
+ }
2246
+ }
2247
+ ```
2248
+
2249
+ > **One Per Entry:** Add a single SEO field to each collection/single that represents a page. It provides everything needed for `&lt;head&gt;` meta tags.
2250
+
2251
+
2252
+ ---
2253
+
2254
+ # URL Field
2255
+
2256
+ URL input with per-locale values. Each configured language gets its own URL input.
2257
+
2258
+ ## Basic Usage
2259
+
2260
+ ```typescript
2261
+ {
2262
+ type: 'url',
2263
+ slug: 'externalLink',
2264
+ label: 'External Link',
2265
+ placeholder: 'https://example.com'
2266
+ }
2267
+ ```
2268
+
2269
+ ## With Options
2270
+
2271
+ ```typescript
2272
+ {
2273
+ type: 'url',
2274
+ slug: 'ctaLink',
2275
+ label: 'CTA Link',
2276
+ placeholder: 'https://',
2277
+ description: 'Link target for the call-to-action button'
2278
+ }
2279
+ ```
2280
+
2281
+ ## Properties
2282
+
2283
+ | Property | Type | Default | Description |
2284
+ |----------|------|---------|-------------|
2285
+ | `placeholder` | `Localized` | — | Input placeholder text |
2286
+ | `text` | `boolean` | — | Show link text input per locale |
2287
+ | `newTab` | `boolean` | — | Show "open in new tab" toggle |
2288
+ | `rel` | `boolean` | — | Show rel attribute input |
2289
+
2290
+ ## Output
2291
+
2292
+ Returns `UrlFieldData`:
2293
+
2294
+ ```typescript
2295
+ type UrlFieldData = {
2296
+ id?: string;
2297
+ url: Record<string, string>; // Language → URL mapping
2298
+ text?: Record<string, string>; // Language → link text mapping
2299
+ newTab?: boolean; // Open in new tab
2300
+ rel?: string; // Rel attribute value
2301
+ };
2302
+
2303
+ // Example (multi-language with text)
2304
+ {
2305
+ externalLink: {
2306
+ url: {
2307
+ en: "https://example.com/about",
2308
+ pl: "https://example.com/pl/o-nas"
2309
+ },
2310
+ text: {
2311
+ en: "About us",
2312
+ pl: "O nas"
2313
+ },
2314
+ newTab: true
2315
+ }
2316
+ }
2317
+ ```
2318
+
2319
+ > **Per-locale URLs:** URL fields automatically provide separate inputs for each configured language — useful for linking to localized external pages.
2320
+
2321
+
2322
+ ---
2323
+
2324
+ # Media
2325
+
2326
+ Includio provides a media library for managing uploaded files, images, and videos.
2327
+
2328
+ ## Features
2329
+
2330
+ - Drag & drop upload with progress indicators
2331
+ - Search by filename or alt text
2332
+ - Fullscreen lightbox preview with checkered background for transparency
2333
+ - Organize with color-coded tags
2334
+ - Automatic image style generation (resize, crop, format conversion) via Sharp
2335
+ - AI-powered alt text generation (requires AI adapter)
2336
+ - Focal point picker for smart cropping
2337
+ - Video metadata: poster images, duration, thumbnails
2338
+ - Accessibility: transcript and audio description file attachments
2339
+
2340
+ ## Upload
2341
+
2342
+ Drag and drop files anywhere in the media library to upload. Multiple files supported.
2343
+
2344
+ - Visual drop zone overlay appears on drag
2345
+ - Per-file progress bars during upload
2346
+ - Automatic refresh after completion
2347
+
2348
+ ## Search & Tags
2349
+
2350
+ Filter media by typing in the search bar (matches filename and alt text).
2351
+
2352
+ Organize media with **tags** — color-coded labels for categorization:
2353
+
2354
+ ```typescript
2355
+ interface MediaTag {
2356
+ id: string;
2357
+ name: string;
2358
+ color: string;
2359
+ createdAt: Date;
2360
+ }
2361
+ ```
2362
+
2363
+ Tags can be created, edited, and bulk-assigned to files in the media library.
2364
+
2365
+ ## Media Files
2366
+
2367
+ Uploaded files are stored using the configured `FilesAdapter` and tracked in the database.
2368
+
2369
+ ```typescript
2370
+ type MediaFileType = 'image' | 'video' | 'audio' | 'pdf' | 'other';
2371
+
2372
+ interface MediaFile {
2373
+ id: string;
2374
+ name: string;
2375
+ url: string;
2376
+ type: MediaFileType;
2377
+ size: number;
2378
+ width: number | null;
2379
+ height: number | null;
2380
+ alt: string | null;
2381
+ tags: MediaTag[];
2382
+ createdAt: Date;
2383
+ mimeType: string | null;
2384
+ blurDataUrl: string | null;
2385
+ focalX: number | null;
2386
+ focalY: number | null;
2387
+ // Video-specific
2388
+ thumbnailUrl: string | null;
2389
+ duration: number | null;
2390
+ posterUrl: string | null;
2391
+ // Accessibility
2392
+ transcriptFileId: string | null;
2393
+ audioDescriptionFileId: string | null;
2394
+ }
2395
+ ```
2396
+
2397
+ ## Image Styles
2398
+
2399
+ When using the `media` field type with `styles`, the CMS generates transformed image variants using Sharp:
2400
+
2401
+ ```typescript
2402
+ {
2403
+ type: 'media',
2404
+ slug: 'hero',
2405
+ accept: 'image/*',
2406
+ styles: [
2407
+ { name: 'thumb', width: 200, height: 200, crop: true },
2408
+ { name: 'medium', width: 800, format: 'webp', quality: 80 },
2409
+ { name: 'large', width: 1600, format: 'webp' },
2410
+ { name: 'og', width: 1200, height: 630, crop: true, format: 'jpeg' }
2411
+ ]
2412
+ }
2413
+ ```
2414
+
2415
+ Generated styles include responsive attributes:
2416
+
2417
+ ```typescript
2418
+ interface ImageStyle {
2419
+ url: string;
2420
+ mimeType: string;
2421
+ media?: string; // Media query
2422
+ srcset?: string; // Responsive srcset
2423
+ sizes?: string; // Sizes attribute
2424
+ }
2425
+ ```
2426
+
2427
+ Styles are generated on upload and stored alongside the original. See [Media Field](/docs/fields/media-field) for full style options.
2428
+
2429
+ > **Sharp Required:** Image processing requires the `sharp` package. It's included as a dependency of `includio-cms`.
2430
+
2431
+ ## AI Alt Text
2432
+
2433
+ With an AI adapter configured, the admin shows a "Generate alt text" button on image files:
2434
+
2435
+ ```typescript
2436
+ import { claudeAdapter } from 'includio-cms/ai-claude';
2437
+ // or
2438
+ import { openAIAdapter } from 'includio-cms/ai-openai';
2439
+
2440
+ export default defineConfig({
2441
+ ai: claudeAdapter({ apiKey: process.env.ANTHROPIC_API_KEY }),
2442
+ // ...
2443
+ });
2444
+ ```
2445
+
2446
+ See [AI Adapter](/docs/adapters/ai) for configuration details.
2447
+
2448
+ ## Video Transcoding
2449
+
2450
+ Includio auto-transcodes uploaded videos to mp4 (h264) and webm (vp9) in the background. Transcoded variants are tracked as `VideoStyle` records:
2451
+
2452
+ ```typescript
2453
+ interface VideoStyle {
2454
+ id: string;
2455
+ mediaFileId: string;
2456
+ name: string;
2457
+ url: string;
2458
+ width: number | null;
2459
+ height: number | null;
2460
+ format: string;
2461
+ codec: string | null;
2462
+ fileSize: number | null;
2463
+ mimeType: string;
2464
+ status: 'pending' | 'processing' | 'done' | 'failed';
2465
+ error: string | null;
2466
+ }
2467
+ ```
2468
+
2469
+ The admin maintenance page provides batch transcode, purge, and retranscode with SSE progress.
2470
+
2471
+ On the frontend, the `<Video>` component serves transcoded sources automatically with Safari-aware ordering.
2472
+
2473
+ See [Video Transcoding](/docs/video-transcoding) for full configuration and usage.
2474
+
2475
+ ## File Adapter
2476
+
2477
+ The file adapter handles physical storage. See [Files Adapter](/docs/adapters/files) for the interface.
2478
+
2479
+
2480
+ ---
2481
+
2482
+ # Video Transcoding
2483
+
2484
+ Includio automatically transcodes uploaded videos to optimized mp4 (h264) and webm (vp9) formats in the background. Added in **v0.14.0**.
2485
+
2486
+ ## Requirements
2487
+
2488
+ - **ffmpeg** with `libx264` and `libvpx-vp9` codecs
2489
+ - Graceful degradation if ffmpeg is unavailable — videos still upload, just no transcoded variants
2490
+
2491
+ Check availability via the System Info endpoint (`GET /admin/api/system-info`).
2492
+
2493
+ ## Configuration
2494
+
2495
+ Enable and configure in your CMS config:
2496
+
2497
+ ```typescript
2498
+ defineConfig({
2499
+ media: {
2500
+ video: {
2501
+ transcode: true, // default: true
2502
+ formats: ['mp4', 'webm'], // default: ['mp4', 'webm']
2503
+ maxResolution: 1080, // default: 1080 (px, longest side)
2504
+ crf: { mp4: 23, webm: 30 },// quality (lower = better, bigger)
2505
+ concurrency: 1 // parallel transcoding jobs
2506
+ }
2507
+ }
2508
+ });
2509
+ ```
2510
+
2511
+ ```typescript
2512
+ interface VideoTranscodeConfig {
2513
+ transcode?: boolean;
2514
+ formats?: ('mp4' | 'webm')[];
2515
+ maxResolution?: number;
2516
+ crf?: { mp4?: number; webm?: number };
2517
+ concurrency?: number;
2518
+ }
2519
+ ```
2520
+
2521
+ ## How It Works
2522
+
2523
+ 1. User uploads a video via admin or API
2524
+ 2. Original file is stored as-is
2525
+ 3. Background job creates transcoded variants (one per configured format)
2526
+ 4. Each variant tracked as a `VideoStyle` with status: `pending` → `processing` → `done` / `failed`
2527
+
2528
+ ### Skip Logic
2529
+
2530
+ **For mp4 target format** — skip only when ALL three conditions are met:
2531
+ 1. Source is already mp4
2532
+ 2. Resolution ≤1080p (longest side)
2533
+ 3. Bitrate ≤8Mbps
2534
+
2535
+ **For webm target format** — always transcode (no skip possible).
2536
+
2537
+ **File size guard**: Files >500MB (`MAX_AUTO_TRANSCODE_SIZE`) are skipped entirely to avoid long processing times. This is a separate check from the format-specific skip logic above.
2538
+
2539
+ ## VideoStyle
2540
+
2541
+ Each transcoded variant produces a `VideoStyle`:
2542
+
2543
+ ```typescript
2544
+ interface VideoStyle {
2545
+ id: string;
2546
+ mediaFileId: string;
2547
+ name: string; // e.g. 'mp4-1080', 'webm-1080'
2548
+ url: string;
2549
+ width: number | null;
2550
+ height: number | null;
2551
+ format: string; // 'mp4' | 'webm'
2552
+ codec: string | null; // 'h264' | 'vp9'
2553
+ fileSize: number | null;
2554
+ mimeType: string;
2555
+ status: 'pending' | 'processing' | 'done' | 'failed';
2556
+ error: string | null;
2557
+ }
2558
+ ```
2559
+
2560
+ ## Admin UI
2561
+
2562
+ The admin maintenance page provides:
2563
+
2564
+ - **Batch transcode** — transcode all videos that don't have styles yet (SSE progress)
2565
+ - **Purge video styles** — delete all transcoded variants from disk and database
2566
+ - **Retranscode** — purge + retranscode all videos
2567
+
2568
+ ## Frontend Rendering
2569
+
2570
+ The `<Video>` component serves transcoded sources automatically:
2571
+
2572
+ ```svelte
2573
+ <Video data={entry.video} controls />
2574
+ ```
2575
+
2576
+ Sources are ordered by `preferMp4` context:
2577
+ - Safari: mp4 first (hardware-accelerated)
2578
+ - Other browsers: webm first (better compression)
2579
+
2580
+ Set preference in your root layout:
2581
+
2582
+ ```svelte
2583
+ <CmsProvider {data}>
2584
+ {@render children()}
2585
+ </CmsProvider>
2586
+ ```
2587
+
2588
+ Pass `cmsContext` from your server load:
2589
+
2590
+ ```typescript
2591
+ // +layout.server.ts
2592
+ import { cmsLayoutLoad } from 'includio-cms/sveltekit/server';
2593
+
2594
+ export function load(event) {
2595
+ return cmsLayoutLoad(event);
2596
+ }
2597
+ ```
2598
+
2599
+ See [Frontend Rendering](/docs/frontend) for all component details.
2600
+
2601
+ ## SQL Migration
2602
+
2603
+ ```sql
2604
+ CREATE TABLE IF NOT EXISTS video_styles (
2605
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
2606
+ media_file_id UUID NOT NULL REFERENCES media_file(id) ON DELETE CASCADE,
2607
+ name TEXT NOT NULL,
2608
+ url TEXT NOT NULL,
2609
+ width INTEGER,
2610
+ height INTEGER,
2611
+ format TEXT NOT NULL,
2612
+ codec TEXT,
2613
+ file_size INTEGER,
2614
+ mime_type TEXT NOT NULL,
2615
+ status TEXT NOT NULL DEFAULT 'pending',
2616
+ error TEXT,
2617
+ created_at TIMESTAMP DEFAULT NOW()
2618
+ );
2619
+
2620
+ CREATE UNIQUE INDEX IF NOT EXISTS video_styles_unique_key
2621
+ ON video_styles (media_file_id, name);
2622
+ ```
2623
+
2624
+ > **System Info:** Use `GET /admin/api/system-info` to check ffmpeg availability, codec support, and disk usage breakdown.
2625
+
2626
+
2627
+ ---
2628
+
2629
+ # Forms
2630
+
2631
+ Collect user submissions through configurable forms with email notifications and file uploads.
2632
+
2633
+ ## Defining a Form
2634
+
2635
+ ```typescript
2636
+ import { defineForm } from 'includio-cms/sveltekit';
2637
+
2638
+ const contact = defineForm({
2639
+ slug: 'contact',
2640
+ label: { en: 'Contact Form' },
2641
+ notificationEmailAddresses: ['admin@example.com'],
2642
+ fields: [
2643
+ { type: 'text', slug: 'name', label: { en: 'Name' }, required: true },
2644
+ { type: 'email', slug: 'email', label: { en: 'Email' }, required: true },
2645
+ { type: 'textarea', slug: 'message', label: { en: 'Message' }, required: true, maxLength: 2000 },
2646
+ { type: 'select', slug: 'topic', label: { en: 'Topic' }, options: [
2647
+ { value: 'general', label: { en: 'General' } },
2648
+ { value: 'support', label: { en: 'Support' } }
2649
+ ]},
2650
+ { type: 'file', slug: 'attachment', label: { en: 'Attachment' }, accept: 'application/pdf', maxSize: 5242880 },
2651
+ { type: 'checkbox', slug: 'consent', label: { en: 'I agree to the privacy policy' }, required: true }
2652
+ ]
2653
+ });
2654
+ ```
2655
+
2656
+ ## Configuration
2657
+
2658
+ | Property | Type | Required | Description |
2659
+ |----------|------|----------|-------------|
2660
+ | `slug` | `string` | Yes | Unique form identifier |
2661
+ | `label` | `Localized` | Yes | Display label in admin |
2662
+ | `fields` | `FormField[]` | Yes | Form field definitions |
2663
+ | `sidebarIcon` | `IconName` | No | Icon for admin sidebar |
2664
+ | `notificationEmailAddresses` | `string[]` | No | Emails notified on submission |
2665
+
2666
+ ## Form Field Types
2667
+
2668
+ | Type | Description | Properties |
2669
+ |------|-------------|------------|
2670
+ | `text` | Single-line text | `minLength`, `maxLength` |
2671
+ | `email` | Email with validation | — |
2672
+ | `textarea` | Multi-line text | `minLength`, `maxLength` |
2673
+ | `checkbox` | Single checkbox (consent, etc.) | — |
2674
+ | `select` | Dropdown select | `options: { value, label? }[]` |
2675
+ | `file` | File upload | `accept` (MIME), `maxSize` (bytes) |
2676
+
2677
+ ### Common Form Field Properties
2678
+
2679
+ | Property | Type | Description |
2680
+ |----------|------|-------------|
2681
+ | `slug` | `string` | Unique identifier |
2682
+ | `label` | `Localized` | Display label |
2683
+ | `required` | `boolean` | Whether the field is required |
2684
+ | `description` | `Localized` | Help text |
2685
+ | `errorMessage` | `Localized` | Custom validation error message |
2686
+ | `defaultValue` | `any` | Default value |
2687
+ | `showInDataTable` | `boolean` | Show column in submissions table |
2688
+
2689
+ > **Content Fields vs Form Fields:** Form fields (`text`, `email`, `textarea`, `checkbox`, `select`, `file`) are separate from content fields. They're designed for simple user-facing forms, not complex content modeling.
2690
+
2691
+ ## Submissions
2692
+
2693
+ Submissions are stored in the database and viewable in the admin panel:
2694
+
2695
+ ```typescript
2696
+ interface FormSubmission {
2697
+ id: string;
2698
+ formSlug: string;
2699
+ createdAt: Date;
2700
+ data: Record<string, unknown>;
2701
+ read: boolean; // Marked as read/unread in admin
2702
+ ip?: string | null; // Submitter's IP
2703
+ userAgent?: string | null;
2704
+ }
2705
+ ```
2706
+
2707
+ ## Public Submission Endpoint
2708
+
2709
+ Forms can be submitted from your frontend via a public POST endpoint:
2710
+
2711
+ ```
2712
+ POST /api/forms/{slug}/submit
2713
+ Content-Type: multipart/form-data
2714
+ ```
2715
+
2716
+ ### Submitting from Frontend
2717
+
2718
+ ```typescript
2719
+ async function submitForm(formData: Record<string, unknown>) {
2720
+ const body = new FormData();
2721
+
2722
+ for (const [key, value] of Object.entries(formData)) {
2723
+ if (value instanceof File) {
2724
+ body.append(key, value);
2725
+ } else {
2726
+ body.append(key, String(value));
2727
+ }
2728
+ }
2729
+
2730
+ const res = await fetch('/api/forms/contact/submit', {
2731
+ method: 'POST',
2732
+ body
2733
+ });
2734
+
2735
+ if (!res.ok) {
2736
+ const error = await res.json();
2737
+ throw new Error(error.message);
2738
+ }
2739
+
2740
+ return res.json();
2741
+ }
2742
+ ```
2743
+
2744
+ ### SvelteKit Form Action
2745
+
2746
+ ```svelte
2747
+ <form method="POST" action="/api/forms/contact/submit" enctype="multipart/form-data">
2748
+ <input name="name" required />
2749
+ <input name="email" type="email" required />
2750
+ <textarea name="message" required></textarea>
2751
+ <input name="attachment" type="file" accept="application/pdf" />
2752
+ <label><input name="consent" type="checkbox" required /> I agree</label>
2753
+ <button type="submit">Send</button>
2754
+ </form>
2755
+ ```
2756
+
2757
+ > **Rate Limiting:** Form submissions are rate-limited to 5 per IP per hour to prevent abuse.
2758
+
2759
+ ## Server-Side Submission
2760
+
2761
+ For custom form handling in `+page.server.ts`, use the server-side helpers:
2762
+
2763
+ ```typescript
2764
+ import { createFormSubmission, parseFormDataForSubmission } from 'includio-cms/sveltekit/server';
2765
+
2766
+ export const actions = {
2767
+ default: async ({ request, getClientAddress }) => {
2768
+ const { data } = await parseFormDataForSubmission(request, 'contact');
2769
+ const success = await createFormSubmission({
2770
+ slug: 'contact',
2771
+ data,
2772
+ ip: getClientAddress(),
2773
+ userAgent: request.headers.get('user-agent') ?? undefined
2774
+ });
2775
+ return { success };
2776
+ }
2777
+ };
2778
+ ```
2779
+
2780
+ | Function | Description |
2781
+ |----------|-------------|
2782
+ | `parseFormDataForSubmission` | Parse multipart form data for a given form slug. Validates against form config. |
2783
+ | `createFormSubmission` | Validate and store submission. Sends notification emails if configured. Returns `boolean`. |
2784
+
2785
+ ## Private File Uploads
2786
+
2787
+ Added in **v0.13.0**. Files uploaded via form submissions are stored in a private directory — they are NOT publicly accessible via URL.
2788
+
2789
+ - Private files can only be downloaded through the admin panel
2790
+ - Requires `FilesAdapter` with `uploadPrivateFile()` implemented (built-in local adapter supports this)
2791
+ - Files are stored separately from public media uploads
2792
+
2793
+ This prevents form attachments (e.g. resumes, documents) from being accessible to anyone with the URL.
2794
+
2795
+ ## Email Notifications
2796
+
2797
+ When `notificationEmailAddresses` is set, the CMS sends an email to those addresses on each submission using the configured email adapter.
2798
+
2799
+ ## Usage in Config
2800
+
2801
+ ```typescript
2802
+ export default defineConfig({
2803
+ forms: [contact, newsletter, feedback],
2804
+ // ...
2805
+ });
2806
+ ```
2807
+
2808
+
2809
+ ---
2810
+
2811
+ # Validation
2812
+
2813
+ Includio uses [Zod](https://zod.dev/) for field validation with Polish error messages.
2814
+
2815
+ ## Required Fields
2816
+
2817
+ Fields with `required: true` display a red asterisk (*) next to their label. On submit, missing required fields trigger validation errors.
2818
+
2819
+ ```typescript
2820
+ {
2821
+ type: 'text',
2822
+ slug: 'title',
2823
+ label: 'Tytuł',
2824
+ required: true // Shows asterisk, validates on save
2825
+ }
2826
+ ```
2827
+
2828
+ ## Validation Messages
2829
+
2830
+ Error messages are displayed in Polish:
2831
+
2832
+ | Error | Message |
2833
+ |-------|---------|
2834
+ | Required field empty | "To pole jest wymagane" |
2835
+ | Invalid email | "Nieprawidłowy adres email" |
2836
+ | Number out of range | "Wartość musi być między X a Y" |
2837
+ | String too short | "Minimum X znaków" |
2838
+ | String too long | "Maximum X znaków" |
2839
+
2840
+ ## Zod Schemas
2841
+
2842
+ Each field type has a corresponding Zod schema. You can extend validation:
2843
+
2844
+ ```typescript
2845
+ {
2846
+ type: 'text',
2847
+ slug: 'email',
2848
+ label: 'Email',
2849
+ validation: z.string().email()
2850
+ }
2851
+ ```
2852
+
2853
+ > **Automatic Error Handling:** Validation errors are automatically handled by the admin UI — shown in toast notifications on save and inline below each field. No manual error handling needed.
2854
+
2855
+ ## Custom Validation
2856
+
2857
+ Add custom validation rules using the `validate` property:
2858
+
2859
+ ```typescript
2860
+ {
2861
+ type: 'text',
2862
+ slug: 'slug',
2863
+ label: 'Slug',
2864
+ validate: async (value, { entry }) => {
2865
+ const exists = await checkSlugExists(value, entry.id);
2866
+ if (exists) return 'Ten slug już istnieje';
2867
+ return true;
2868
+ }
2869
+ }
2870
+ ```
2871
+
2872
+ ## Client-Side vs Server-Side
2873
+
2874
+ - **Client-side:** Immediate feedback as user types (debounced)
2875
+ - **Server-side:** Full validation on form submit before save
2876
+
2877
+ Both use the same Zod schemas for consistency.
2878
+
2879
+
2880
+ ---
2881
+
2882
+ # Adapters
2883
+
2884
+ Adapters are pluggable backends for core CMS functionality. They abstract away implementation details so you can swap providers without changing your content model.
2885
+
2886
+ ## Required Adapters
2887
+
2888
+ | Adapter | Purpose | Built-in |
2889
+ |---------|---------|----------|
2890
+ | [Database](/docs/adapters/database) | Store entries, versions, media metadata, forms | `includio-cms/db-postgres` |
2891
+ | [Files](/docs/adapters/files) | Store/retrieve uploaded files | `includio-cms/files-local` |
2892
+ | [Email](/docs/adapters/email) | Send notification emails | `includio-cms/email-nodemailer` |
2893
+
2894
+ ## Optional Adapters
2895
+
2896
+ | Adapter | Purpose | Built-in |
2897
+ |---------|---------|----------|
2898
+ | [AI](/docs/adapters/ai) | Image alt text generation | `includio-cms/ai-claude`, `includio-cms/ai-openai` |
2899
+
2900
+ ## Configuration
2901
+
2902
+ ```typescript
2903
+ import { pg } from 'includio-cms/db-postgres';
2904
+ import { local } from 'includio-cms/files-local';
2905
+ import { nodemailerAdapter } from 'includio-cms/email-nodemailer';
2906
+ import { openAIAdapter } from 'includio-cms/ai-openai';
2907
+
2908
+ export default defineConfig({
2909
+ db: pg({ databaseUrl: process.env.DATABASE_URL }),
2910
+ files: local(),
2911
+ email: nodemailerAdapter({
2912
+ defaultFromAddress: process.env.SMTP_FROM,
2913
+ defaultFromName: 'My Site',
2914
+ transportOptions: {
2915
+ host: process.env.SMTP_HOST,
2916
+ port: 587,
2917
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
2918
+ }
2919
+ }),
2920
+ ai: openAIAdapter({ apiKey: process.env.OPENAI_API_KEY }) // Optional
2921
+ });
2922
+ ```
2923
+
2924
+ ## Custom Adapters
2925
+
2926
+ Implement custom adapters by conforming to the TypeScript interfaces:
2927
+
2928
+ ```typescript
2929
+ import type { DatabaseAdapter } from 'includio-cms/types';
2930
+
2931
+ const myCustomDb: DatabaseAdapter = {
2932
+ createEntry: async (data) => { /* ... */ },
2933
+ getEntries: async (data) => { /* ... */ },
2934
+ // ... implement all required methods
2935
+ };
2936
+ ```
2937
+
2938
+ > **Custom Adapters:** You can build adapters for any provider (MySQL, S3, Resend, Claude, etc). Just implement the required interface methods and pass the adapter to `defineConfig`.
2939
+
2940
+
2941
+ ---
2942
+
2943
+ # Database Adapter
2944
+
2945
+ The database adapter handles all data persistence: entries, versions, media metadata, form submissions, tags, video styles, and consent logs.
2946
+
2947
+ ## Interface
2948
+
2949
+ ```typescript
2950
+ interface DatabaseAdapter {
2951
+ // Entries
2952
+ createEntry: (data: DbEntryInsert) => Promise<DbEntry>;
2953
+ getEntries: (data: GetDbEntriesOptions) => Promise<DbEntry[]>;
2954
+ countEntries: (data: Omit<GetDbEntriesOptions, 'limit' | 'offset' | 'orderBy'>) => Promise<number>;
2955
+ updateEntry: (data: { id: string; data: Partial<DbEntry> }) => Promise<DbEntry>;
2956
+ archiveEntry: (data: { id: string; archivedAt?: Date }) => Promise<DbEntry>;
2957
+ deleteEntry: (data: { id: string }) => Promise<void>;
2958
+
2959
+ // Versions
2960
+ createEntryVersion: (data: DbEntryVersionInsert) => Promise<DbEntryVersion>;
2961
+ updateEntryVersion: (data: { id: string; data: Partial<DbEntryVersion> }) => Promise<DbEntryVersion>;
2962
+ getEntryVersions: (data: GetDbEntryVersionsOptions) => Promise<DbEntryVersion[]>;
2963
+ deleteEntryVersion: (data: { id: string }) => Promise<void>;
2964
+
2965
+ // Pagination (optional)
2966
+ getPaginatedEntries?: (data: GetPaginatedEntriesOptions) => Promise<PaginatedEntryRow[]>;
2967
+ countPaginatedEntries?: (data: Omit<GetPaginatedEntriesOptions, 'limit' | 'offset' | 'orderBy'>) => Promise<number>;
2968
+
2969
+ // Forms
2970
+ createFormSubmission: (data: CreateFormSubmissionData) => Promise<void>;
2971
+ getFormSubmissions: (slug: string) => Promise<FormSubmission[]>;
2972
+ getFormSubmission: (id: string) => Promise<FormSubmission | null>;
2973
+ updateFormSubmission: (id: string, data: Partial<FormSubmission>) => Promise<FormSubmission>;
2974
+ deleteFormSubmission: (id: string) => Promise<void>;
2975
+
2976
+ // Consent
2977
+ createConsentLog: (data: ConsentLogData) => Promise<void>;
2978
+
2979
+ // Media Tags
2980
+ getMediaTags: () => Promise<MediaTag[]>;
2981
+ createMediaTag: (data: { name: string; color: string }) => Promise<MediaTag>;
2982
+ updateMediaTag: (data: { id: string; name?: string; color?: string }) => Promise<MediaTag>;
2983
+ deleteMediaTag: (id: string) => Promise<void>;
2984
+ setMediaFileTags: (fileId: string, tagIds: string[]) => Promise<void>;
2985
+ bulkSetMediaFileTags: (fileIds: string[], tagIds: string[]) => Promise<void>;
2986
+ getMediaTagsWithCounts: () => Promise<{ tag: MediaTag; count: number }[]>;
2987
+
2988
+ // Media Files
2989
+ createMediaFile: (data: MediaFile) => Promise<MediaFile>;
2990
+ getMediaFiles: (data: GetMediaFilesOptions) => Promise<MediaFile[]>;
2991
+ countMediaFiles: (data: GetMediaFilesOptions) => Promise<number>;
2992
+ getMediaFile: (data: GetMediaFileOptions) => Promise<MediaFile | null>;
2993
+ updateMediaFile: (data: { id: string; data: Partial<MediaFile> }) => Promise<MediaFile>;
2994
+ deleteMediaFile: (id: string) => Promise<void>;
2995
+ bulkDeleteMediaFiles: (ids: string[]) => Promise<void>;
2996
+
2997
+ // Image Styles
2998
+ createImageStyle: (mediaFileId: string, file: UploadedMediaFile, style: ImageFieldStyle) => Promise<ImageStyle>;
2999
+ getImageStyle: (mediaFileId: string, style: ImageFieldStyle) => Promise<ImageStyle | null>;
3000
+ deleteImageStylesByMediaFileId: (mediaFileId: string) => Promise<{ url: string }[]>;
3001
+ getAllImageStyles: () => Promise<{ id: string; url: string; mediaFileId: string }[]>;
3002
+ deleteAllImageStyles: () => Promise<number>;
3003
+
3004
+ // Video Styles
3005
+ createVideoStyle: (data: CreateVideoStyleData) => Promise<VideoStyle>;
3006
+ getVideoStylesByMediaFileId: (mediaFileId: string) => Promise<VideoStyle[]>;
3007
+ updateVideoStyleStatus: (id: string, status: VideoStyleStatus, data?: Partial<VideoStyleUpdate>) => Promise<VideoStyle>;
3008
+ deleteVideoStylesByMediaFileId: (mediaFileId: string) => Promise<{ url: string }[]>;
3009
+ getAllVideoStyles: () => Promise<{ id: string; url: string; mediaFileId: string; status: VideoStyleStatus }[]>;
3010
+ deleteAllVideoStyles: () => Promise<number>;
3011
+ }
3012
+ ```
3013
+
3014
+ ## Built-in: PostgreSQL
3015
+
3016
+ ```typescript
3017
+ import { pg } from 'includio-cms/db-postgres';
3018
+
3019
+ db: pg({
3020
+ databaseUrl: 'postgres://user:pass@localhost:5432/mydb'
3021
+ })
3022
+ ```
3023
+
3024
+ Uses Drizzle ORM under the hood. Run `pnpm db:push` to sync the schema.
3025
+
3026
+ ## Key Types
3027
+
3028
+ ### Entry Queries
3029
+
3030
+ ```typescript
3031
+ interface GetDbEntriesOptions {
3032
+ ids?: string[];
3033
+ slug?: string;
3034
+ type?: 'collection' | 'singleton';
3035
+ onlyArchived?: boolean;
3036
+ }
3037
+
3038
+ interface GetDbEntryVersionsOptions {
3039
+ ids?: string[];
3040
+ entryIds?: string[];
3041
+ lang?: string;
3042
+ status?: 'draft' | 'published' | 'scheduled';
3043
+ dataValues?: Record<string, unknown>;
3044
+ dataLike?: Record<string, unknown>;
3045
+ dataILikeOr?: Record<string, unknown>;
3046
+ dataOrderBy?: { field: string; direction: 'asc' | 'desc' };
3047
+ limit?: number;
3048
+ offset?: number;
3049
+ orderBy?: { column: string; direction: 'asc' | 'desc' };
3050
+ }
3051
+ ```
3052
+
3053
+ ### Media Queries
3054
+
3055
+ ```typescript
3056
+ interface GetMediaFilesOptions {
3057
+ data: {
3058
+ tagIds?: string[];
3059
+ ids?: string[];
3060
+ mimeTypes?: string[];
3061
+ search?: string;
3062
+ untagged?: boolean;
3063
+ limit?: number;
3064
+ offset?: number;
3065
+ };
3066
+ }
3067
+ ```
3068
+
3069
+ ### Video Styles
3070
+
3071
+ ```typescript
3072
+ interface CreateVideoStyleData {
3073
+ mediaFileId: string;
3074
+ name: string;
3075
+ url: string;
3076
+ width: number | null;
3077
+ height: number | null;
3078
+ format: string;
3079
+ codec: string | null;
3080
+ fileSize: number | null;
3081
+ mimeType: string;
3082
+ status: VideoStyleStatus; // 'pending' | 'processing' | 'done' | 'failed'
3083
+ }
3084
+ ```
3085
+
3086
+ > **Custom Implementation:** To use MySQL, MongoDB, or another database, implement all methods in the `DatabaseAdapter` interface. Each method has clear input/output types. Optional methods (`getPaginatedEntries`, `countPaginatedEntries`) can be omitted.
3087
+
3088
+
3089
+ ---
3090
+
3091
+ # Files Adapter
3092
+
3093
+ The files adapter handles physical file storage and retrieval.
3094
+
3095
+ ## Interface
3096
+
3097
+ ```typescript
3098
+ interface FilesAdapter {
3099
+ downloadFile: (id: string) => Promise<File | null>;
3100
+ uploadFile: (file: File) => Promise<UploadedMediaFile>;
3101
+ renameFile: (url: string, oldName: string, newName: string) => Promise<
3102
+ | { success: true; url: string; name: string }
3103
+ | { success: false; error: 'name-already-exists' }
3104
+ >;
3105
+ deleteFile: (filename: string) => Promise<void>;
3106
+ listFiles: () => Promise<string[]>;
3107
+
3108
+ // Optional — for private form submission files (v0.13.0)
3109
+ uploadPrivateFile?: (file: File) => Promise<{ filename: string; url: string }>;
3110
+ downloadPrivateFile?: (filename: string) => Promise<File | null>;
3111
+ deletePrivateFile?: (filename: string) => Promise<void>;
3112
+ }
3113
+ ```
3114
+
3115
+ ## Methods
3116
+
3117
+ | Method | Required | Description |
3118
+ |--------|----------|-------------|
3119
+ | `downloadFile` | Yes | Retrieve a file by ID. Returns `null` if not found. |
3120
+ | `uploadFile` | Yes | Store a file and return metadata (URL, size, etc). |
3121
+ | `renameFile` | Yes | Rename a file. Returns error if name conflicts. |
3122
+ | `deleteFile` | Yes | Delete a file by filename. |
3123
+ | `listFiles` | Yes | List all stored filenames. Used by media garbage collection. |
3124
+ | `uploadPrivateFile` | No | Upload to non-public directory. Used for form file submissions. |
3125
+ | `downloadPrivateFile` | No | Download private file by filename. |
3126
+ | `deletePrivateFile` | No | Delete private file by filename. |
3127
+
3128
+ ## Built-in: Local Filesystem
3129
+
3130
+ ```typescript
3131
+ import { local } from 'includio-cms/files-local';
3132
+
3133
+ files: local()
3134
+ ```
3135
+
3136
+ Stores files in the project's `static/uploads` directory. Files are accessible at `/uploads/filename.ext`.
3137
+
3138
+ Private files are stored in `data/private-uploads/` — not publicly accessible.
3139
+
3140
+ > **Production Storage:** The local adapter is ideal for development. For production, implement a custom adapter that stores files in S3, Cloudflare R2, or another object storage service.
3141
+
3142
+ ## Private File Uploads
3143
+
3144
+ Added in **v0.13.0**. Form submissions with file fields upload to a private directory — files are NOT publicly accessible and can only be retrieved through the admin API.
3145
+
3146
+ The built-in local adapter implements all three private methods. Custom adapters can optionally implement them to support form file uploads.
3147
+
3148
+ ## Custom Adapter Example
3149
+
3150
+ ```typescript
3151
+ import type { FilesAdapter } from 'includio-cms/types';
3152
+
3153
+ const s3Files: FilesAdapter = {
3154
+ async uploadFile(file) {
3155
+ const key = `uploads/${Date.now()}-${file.name}`;
3156
+ await s3.putObject({ Key: key, Body: file });
3157
+ return { url: `https://cdn.example.com/${key}`, name: file.name, size: file.size };
3158
+ },
3159
+ async downloadFile(id) {
3160
+ const response = await s3.getObject({ Key: id });
3161
+ return new File([response.Body], id);
3162
+ },
3163
+ async renameFile(url, oldName, newName) {
3164
+ // Copy to new key, delete old
3165
+ return { success: true, url: newUrl, name: newName };
3166
+ },
3167
+ async deleteFile(filename) {
3168
+ await s3.deleteObject({ Key: `uploads/${filename}` });
3169
+ },
3170
+ async listFiles() {
3171
+ const result = await s3.listObjects({ Prefix: 'uploads/' });
3172
+ return result.Contents.map(obj => obj.Key.replace('uploads/', ''));
3173
+ }
3174
+ };
3175
+ ```
3176
+
3177
+
3178
+ ---
3179
+
3180
+ # Email Adapter
3181
+
3182
+ The email adapter sends notification emails for form submissions and other CMS events.
3183
+
3184
+ ## Interface
3185
+
3186
+ ```typescript
3187
+ interface EmailAdapter {
3188
+ sendMail: (options: SendMailOptions) => Promise<void>;
3189
+ defaultFromAddress: string;
3190
+ defaultFromName: string;
3191
+ }
3192
+
3193
+ interface SendMailOptions {
3194
+ to: string | string[];
3195
+ subject: string;
3196
+ html: string;
3197
+ }
3198
+ ```
3199
+
3200
+ ## Properties
3201
+
3202
+ | Property | Type | Description |
3203
+ |----------|------|-------------|
3204
+ | `sendMail` | Function | Send an email with HTML body |
3205
+ | `defaultFromAddress` | `string` | Default sender email address |
3206
+ | `defaultFromName` | `string` | Default sender display name |
3207
+
3208
+ ## Built-in: Nodemailer
3209
+
3210
+ ```typescript
3211
+ import { nodemailerAdapter } from 'includio-cms/email-nodemailer';
3212
+
3213
+ email: nodemailerAdapter({
3214
+ defaultFromAddress: 'noreply@example.com',
3215
+ defaultFromName: 'My Site',
3216
+ transportOptions: {
3217
+ host: 'smtp.example.com',
3218
+ port: 587,
3219
+ auth: {
3220
+ user: process.env.SMTP_USER,
3221
+ pass: process.env.SMTP_PASS
3222
+ }
3223
+ }
3224
+ })
3225
+ ```
3226
+
3227
+ > **Transactional Email:** For production, use a transactional email service (SendGrid, Postmark, Resend, etc). Implement the `EmailAdapter` interface with their SDK.
3228
+
3229
+ ## Custom Adapter Example
3230
+
3231
+ ```typescript
3232
+ import type { EmailAdapter } from 'includio-cms/types';
3233
+
3234
+ const resendEmail: EmailAdapter = {
3235
+ defaultFromAddress: 'noreply@example.com',
3236
+ defaultFromName: 'My CMS',
3237
+ async sendMail({ to, subject, html }) {
3238
+ await resend.emails.send({
3239
+ from: 'noreply@example.com',
3240
+ to: Array.isArray(to) ? to : [to],
3241
+ subject,
3242
+ html
3243
+ });
3244
+ }
3245
+ };
3246
+ ```
3247
+
3248
+
3249
+ ---
3250
+
3251
+ # AI Adapter
3252
+
3253
+ The AI adapter provides AI-powered features. Currently supports automatic alt text generation for images.
3254
+
3255
+ ## Interface
3256
+
3257
+ ```typescript
3258
+ interface AIAdapter {
3259
+ generateAltText: (fileId: string) => Promise<string>;
3260
+ }
3261
+
3262
+ interface AIConfig {
3263
+ apiKey: string;
3264
+ }
3265
+ ```
3266
+
3267
+ ## Built-in: Claude
3268
+
3269
+ ```typescript
3270
+ import { claudeAdapter } from 'includio-cms/ai-claude';
3271
+
3272
+ ai: claudeAdapter({
3273
+ apiKey: process.env.ANTHROPIC_API_KEY
3274
+ })
3275
+ ```
3276
+
3277
+ Uses Claude Haiku with vision to analyze uploaded images and generate descriptive alt text. Converts images to PNG before analysis for consistent results.
3278
+
3279
+ ## Built-in: OpenAI
3280
+
3281
+ ```typescript
3282
+ import { openAIAdapter } from 'includio-cms/ai-openai';
3283
+
3284
+ ai: openAIAdapter({
3285
+ apiKey: process.env.OPENAI_API_KEY
3286
+ })
3287
+ ```
3288
+
3289
+ Uses GPT-4 Vision to analyze uploaded images and generate descriptive alt text.
3290
+
3291
+ ## Features
3292
+
3293
+ | Feature | Description |
3294
+ |---------|-------------|
3295
+ | Alt text generation | Analyzes image content and generates descriptive text for accessibility |
3296
+
3297
+ The adapter is optional. If not configured, AI features are hidden in the admin UI.
3298
+
3299
+ > **Optional:** The AI adapter is the only optional adapter. All other adapters (database, files) are required for the CMS to function. Email is also optional.
3300
+
3301
+ ## Custom Adapter
3302
+
3303
+ Implement the `AIAdapter` interface for other providers:
3304
+
3305
+ ```typescript
3306
+ import type { AIAdapter } from 'includio-cms/types';
3307
+
3308
+ const customAI: AIAdapter = {
3309
+ async generateAltText(fileId) {
3310
+ // Fetch image, analyze, return alt text string
3311
+ return 'Descriptive alt text';
3312
+ }
3313
+ };
3314
+ ```
3315
+
3316
+
3317
+ ---
3318
+
3319
+ # Authentication
3320
+
3321
+ Includio uses [better-auth](https://www.better-auth.com/) for session-based authentication in the admin panel.
3322
+
3323
+ ## Overview
3324
+
3325
+ - Users stored in the database with secure password hashing
3326
+ - Sessions use secure HTTP-only cookies
3327
+ - Write operations (commands) require authentication
3328
+ - Read operations (queries) are public by default
3329
+ - Supports email/password and Google OAuth
3330
+
3331
+ ## Configuration
3332
+
3333
+ ```typescript
3334
+ interface AuthConfig {
3335
+ secret: string; // Session secret (required)
3336
+ baseURL?: string; // Base URL for auth callbacks
3337
+ }
3338
+ ```
3339
+
3340
+ ```typescript
3341
+ defineConfig({
3342
+ auth: {
3343
+ secret: process.env.AUTH_SECRET
3344
+ },
3345
+ // ...
3346
+ });
3347
+ ```
3348
+
3349
+ ## Creating Users
3350
+
3351
+ Use the CLI command to create admin users:
3352
+
3353
+ ```bash
3354
+ pnpm create:user
3355
+ ```
3356
+
3357
+ Follow the prompts to enter email and password.
3358
+
3359
+ ## User Roles
3360
+
3361
+ | Role | Permissions |
3362
+ |------|-------------|
3363
+ | `admin` | Full access: create/edit/delete entries, manage users, settings |
3364
+ | `user` | Standard content editing access |
3365
+
3366
+ ## API Keys
3367
+
3368
+ For programmatic REST API access, configure API keys:
3369
+
3370
+ ```typescript
3371
+ interface ApiKeyConfig {
3372
+ key: string;
3373
+ name?: string; // Descriptive name
3374
+ role?: 'admin' | 'editor'; // Permission level
3375
+ }
3376
+ ```
3377
+
3378
+ ```typescript
3379
+ defineConfig({
3380
+ apiKeys: [
3381
+ { key: process.env.API_KEY, name: 'Frontend app', role: 'editor' }
3382
+ ],
3383
+ // ...
3384
+ });
3385
+ ```
3386
+
3387
+ API keys are sent via HTTP header when making REST API requests. See [API Reference](/docs/api).
3388
+
3389
+ ## Session Flow
3390
+
3391
+ 1. User enters email/password on the login page
3392
+ 2. Server validates credentials against the database
3393
+ 3. Session token is created and stored as HTTP-only cookie
3394
+ 4. Subsequent requests include the session cookie automatically
3395
+
3396
+ ## Admin Access
3397
+
3398
+ The admin panel at `/admin` requires authentication. Unauthenticated users are redirected to the login page.
3399
+
3400
+ ## Auth Middleware
3401
+
3402
+ Commands (write operations) require authentication:
3403
+
3404
+ ```typescript
3405
+ import { requireAuth } from './middleware/auth.js';
3406
+
3407
+ export const createEntry = command(schema, async (input) => {
3408
+ requireAuth(); // Throws 401 if not authenticated
3409
+ // ... create the entry
3410
+ });
3411
+ ```
3412
+
3413
+ Queries (read operations) are public, enabling frontend content access:
3414
+
3415
+ ```typescript
3416
+ export const getEntries = query(schema, async (input) => {
3417
+ // No auth required — public content access
3418
+ return await db.getEntries(input);
3419
+ });
3420
+ ```
3421
+
3422
+ > **Public Queries:** Read-only queries like `getEntries` and `getEntry` don't require auth. This lets your frontend fetch published content without credentials.
3423
+
3424
+ ## Password Reset
3425
+
3426
+ Users can reset their password via email:
3427
+
3428
+ 1. User clicks "Forgot password" on the login page
3429
+ 2. CMS sends a reset link to the registered email
3430
+ 3. User follows the link and sets a new password
3431
+
3432
+ Requires the **email adapter** to be configured. Without it, password reset is unavailable.
3433
+
3434
+ ## Security
3435
+
3436
+ | Feature | Implementation |
3437
+ |---------|---------------|
3438
+ | Password hashing | Secure one-way hash |
3439
+ | Session tokens | `@oslojs/crypto` SHA-256 |
3440
+ | Cookie flags | `httpOnly`, `secure`, `sameSite` |
3441
+ | Session expiry | Automatic timeout |
3442
+ | API key comparison | Timing-safe (v0.13.3) |
3443
+ | Security headers | X-Content-Type-Options, X-Frame-Options, Referrer-Policy (v0.13.3) |
3444
+ | Upload protection | MIME type blocklist (v0.13.3) |
3445
+
3446
+
3447
+ ---
3448
+
3449
+ # Plugins
3450
+
3451
+ Plugins extend CMS behavior through lifecycle hooks that run before or after CRUD operations.
3452
+
3453
+ ## Defining a Plugin
3454
+
3455
+ ```typescript
3456
+ import type { PluginConfig } from 'includio-cms/types';
3457
+
3458
+ const auditLog: PluginConfig = {
3459
+ slug: 'audit-log',
3460
+ hooks: {
3461
+ afterCreate: async (entry) => {
3462
+ console.log('Created entry:', entry.id, entry.slug);
3463
+ },
3464
+ afterUpdate: async (entry) => {
3465
+ console.log('Updated entry:', entry.id);
3466
+ },
3467
+ afterDelete: async (id) => {
3468
+ console.log('Deleted entry:', id);
3469
+ }
3470
+ }
3471
+ };
3472
+ ```
3473
+
3474
+ ## Hook Lifecycle
3475
+
3476
+ Hooks execute in this order:
3477
+
3478
+ 1. `beforeCreate` / `beforeUpdate` / `beforeDelete` — Before the database operation
3479
+ 2. **Database operation executes**
3480
+ 3. `afterCreate` / `afterUpdate` / `afterDelete` — After the operation succeeds
3481
+
3482
+ ## Available Hooks
3483
+
3484
+ | Hook | Arguments | Description |
3485
+ |------|-----------|-------------|
3486
+ | `beforeCreate` | `data: Record<string, unknown>` | Before entry creation. Receives the entry data. |
3487
+ | `afterCreate` | `entry: RawEntry` | After entry creation. Receives the full entry. |
3488
+ | `beforeUpdate` | `id: string, data: Record<string, unknown>` | Before entry update. |
3489
+ | `afterUpdate` | `entry: RawEntry` | After entry update. |
3490
+ | `beforeDelete` | `id: string` | Before entry deletion. |
3491
+ | `afterDelete` | `id: string` | After entry deletion. |
3492
+
3493
+ ## Usage in Config
3494
+
3495
+ ```typescript
3496
+ export default defineConfig({
3497
+ plugins: [auditLog, webhooks, cacheInvalidation],
3498
+ // ...
3499
+ });
3500
+ ```
3501
+
3502
+ Multiple plugins are executed in order. All plugins receive the same hook calls.
3503
+
3504
+ ## Real-world Example: Webhook Plugin
3505
+
3506
+ ```typescript
3507
+ const webhooks: PluginConfig = {
3508
+ slug: 'webhooks',
3509
+ hooks: {
3510
+ afterCreate: async (entry) => {
3511
+ await fetch('https://api.example.com/webhook', {
3512
+ method: 'POST',
3513
+ headers: { 'Content-Type': 'application/json' },
3514
+ body: JSON.stringify({
3515
+ event: 'entry.created',
3516
+ entry: { id: entry.id, slug: entry.slug, type: entry.type }
3517
+ })
3518
+ });
3519
+ },
3520
+ afterUpdate: async (entry) => {
3521
+ await fetch('https://api.example.com/webhook', {
3522
+ method: 'POST',
3523
+ body: JSON.stringify({ event: 'entry.updated', entry: { id: entry.id } })
3524
+ });
3525
+ },
3526
+ afterDelete: async (id) => {
3527
+ await fetch('https://api.example.com/webhook', {
3528
+ method: 'POST',
3529
+ body: JSON.stringify({ event: 'entry.deleted', id })
3530
+ });
3531
+ }
3532
+ }
3533
+ };
3534
+ ```
3535
+
3536
+ > **Use Cases:** Common plugins: webhook triggers, cache invalidation (CDN purge), search index updates, external API sync, Slack notifications, analytics events.
3537
+
3538
+ ## Plugin Interface
3539
+
3540
+ ```typescript
3541
+ interface PluginConfig {
3542
+ slug: string;
3543
+ fields?: CustomFieldDefinition[];
3544
+ hooks?: {
3545
+ beforeCreate?: (data: Record<string, unknown>) => Promise<void>;
3546
+ afterCreate?: (entry: RawEntry) => Promise<void>;
3547
+ beforeUpdate?: (id: string, data: Record<string, unknown>) => Promise<void>;
3548
+ afterUpdate?: (entry: RawEntry) => Promise<void>;
3549
+ beforeDelete?: (id: string) => Promise<void>;
3550
+ afterDelete?: (id: string) => Promise<void>;
3551
+ };
3552
+ }
3553
+ ```
3554
+
3555
+ ## Custom Field Types
3556
+
3557
+ Plugins can register custom field types that appear alongside built-in fields:
3558
+
3559
+ ```typescript
3560
+ import type { PluginConfig, CustomFieldDefinition } from 'includio-cms/types';
3561
+ import { z } from 'zod';
3562
+
3563
+ const photoGrid: CustomFieldDefinition = {
3564
+ fieldType: 'photo-grid',
3565
+ // Lazy-loaded Svelte component for the admin editor
3566
+ component: () => import('./PhotoGridEditor.svelte'),
3567
+ // Zod validation schema
3568
+ zodSchema: (field, languages) =>
3569
+ z.array(z.object({ url: z.string(), caption: z.string().optional() })),
3570
+ // TypeScript type string for codegen
3571
+ tsType: '{ url: string; caption?: string }[]',
3572
+ // Transform value when served via API
3573
+ populateResolver: async (value, field) => value
3574
+ };
3575
+
3576
+ const myPlugin: PluginConfig = {
3577
+ slug: 'photo-grid-plugin',
3578
+ fields: [photoGrid]
3579
+ };
3580
+ ```
3581
+
3582
+ ### CustomFieldDefinition
3583
+
3584
+ | Property | Type | Description |
3585
+ |----------|------|-------------|
3586
+ | `fieldType` | `string` | Unique type slug (used in `{ type: 'custom', fieldType: '...' }`) |
3587
+ | `component` | `() => Promise<{ default: Component }>` | Lazy-loaded Svelte editor component |
3588
+ | `zodSchema` | `(field, languages) => ZodType` | Validation schema factory |
3589
+ | `tsType` | `string` | TypeScript type for generated types |
3590
+ | `populateResolver` | `(value, field) => Promise<unknown>` | Transform value on API output |
3591
+
3592
+ ### Using a Custom Field
3593
+
3594
+ ```typescript
3595
+ {
3596
+ type: 'custom',
3597
+ slug: 'gallery',
3598
+ fieldType: 'photo-grid', // Matches CustomFieldDefinition.fieldType
3599
+ label: 'Photo Gallery',
3600
+ config: { maxPhotos: 12 } // Passed to your component
3601
+ }
3602
+ ```
3603
+
3604
+
3605
+ ---
3606
+
3607
+ # Admin UI
3608
+
3609
+ Includio's admin panel uses a layered glass depth system for visual hierarchy. Plugin developers can use these classes for consistent UI.
3610
+
3611
+ ## Depth System
3612
+
3613
+ CSS variables define background colors, borders, and shadows at different depth levels:
3614
+
3615
+ ```css
3616
+ /* Background colors */
3617
+ --depth-0-bg /* Base layer */
3618
+ --depth-1-bg /* First level */
3619
+ --depth-2-bg /* Second level */
3620
+ --depth-3-bg /* Third level (modals, popovers) */
3621
+
3622
+ /* Borders */
3623
+ --depth-border-1 /* Subtle border */
3624
+ --depth-border-2 /* More prominent border */
3625
+
3626
+ /* Shadows */
3627
+ --depth-shadow-sm /* Small elevation */
3628
+ --depth-shadow-md /* Medium elevation */
3629
+ ```
3630
+
3631
+ ## Glass Classes
3632
+
3633
+ Utility classes apply the depth system with glass morphism effects:
3634
+
3635
+ ```html
3636
+ <div class="glass-depth-1">First level panel</div>
3637
+ <div class="glass-depth-2">Second level panel</div>
3638
+ <div class="glass-depth-3">Modal or popover</div>
3639
+ <div class="glass-inset">Inset/recessed area</div>
3640
+ ```
3641
+
3642
+ Each class applies:
3643
+ - Background color from depth variable
3644
+ - Appropriate border
3645
+ - Backdrop blur for glass effect
3646
+ - Shadow for elevation
3647
+
3648
+ ## Dark Mode
3649
+
3650
+ The depth system automatically adjusts for dark mode. Variables are redefined in `[data-theme="dark"]` context:
3651
+
3652
+ - Backgrounds shift to darker grays
3653
+ - Borders become more subtle
3654
+ - Glass effect uses different opacity
3655
+
3656
+ > **Automatic Switching:** No extra classes needed. The same `glass-depth-*` classes work in both light and dark modes.
3657
+
3658
+ ## Usage Examples
3659
+
3660
+ **Card component:**
3661
+ ```svelte
3662
+ <div class="glass-depth-1 rounded-lg p-4">
3663
+ <h3>Card Title</h3>
3664
+ <p>Card content...</p>
3665
+ </div>
3666
+ ```
3667
+
3668
+ **Modal overlay:**
3669
+ ```svelte
3670
+ <div class="glass-depth-3 rounded-xl p-6 shadow-lg">
3671
+ <h2>Modal</h2>
3672
+ <div class="glass-inset rounded p-3">
3673
+ Inset content area
3674
+ </div>
3675
+ </div>
3676
+ ```
3677
+
3678
+ **Nested panels:**
3679
+ ```svelte
3680
+ <div class="glass-depth-1 p-4">
3681
+ Outer panel
3682
+ <div class="glass-depth-2 mt-2 p-3">
3683
+ Inner panel
3684
+ </div>
3685
+ </div>
3686
+ ```
3687
+
3688
+ ## For Plugin Developers
3689
+
3690
+ When building admin UI plugins:
3691
+
3692
+ 1. Use `glass-depth-*` classes instead of raw Tailwind backgrounds
3693
+ 2. Follow the depth hierarchy (1 → 2 → 3)
3694
+ 3. Use `glass-inset` for input areas or recessed sections
3695
+ 4. Test in both light and dark modes
3696
+
3697
+
3698
+ ---
3699
+
3700
+ # Frontend Rendering
3701
+
3702
+ Includio provides Svelte components and utilities for rendering CMS content on the frontend. All exports come from `includio-cms/sveltekit`.
3703
+
3704
+ ```typescript
3705
+ import {
3706
+ CmsProvider, Image, Video, Media,
3707
+ StructuredContent, HybridTarget, Preview,
3708
+ setPreferMp4, enableHybridEditing,
3709
+ structuredToHtml, getLink,
3710
+ isImageFieldData, isVideoFieldData,
3711
+ extractBlocks, extractInlineBlocks, extractText, extractMediaRefs
3712
+ } from 'includio-cms/sveltekit';
3713
+ ```
3714
+
3715
+ ## Components
3716
+
3717
+ ### CmsProvider
3718
+
3719
+ Root wrapper that passes CMS context (e.g. `preferMp4` for video source ordering).
3720
+
3721
+ ```svelte
3722
+ <!-- +layout.svelte -->
3723
+ <CmsProvider {data}>
3724
+ {@render children()}
3725
+ </CmsProvider>
3726
+ ```
3727
+
3728
+ Pass `cmsContext` from server using `cmsLayoutLoad`:
3729
+
3730
+ ```typescript
3731
+ // +layout.server.ts
3732
+ import { cmsLayoutLoad } from 'includio-cms/sveltekit/server';
3733
+
3734
+ export function load(event) {
3735
+ return cmsLayoutLoad(event);
3736
+ }
3737
+ ```
3738
+
3739
+ ### Image
3740
+
3741
+ Renders `ImageFieldData` or a raw `MediaFile` with responsive `<picture>`, blur placeholder, and srcset support.
3742
+
3743
+ ```svelte
3744
+ <Image data={entry.cover} alt="Cover image" sizes="(max-width: 768px) 100vw, 50vw" />
3745
+ ```
3746
+
3747
+ | Prop | Type | Description |
3748
+ |------|------|-------------|
3749
+ | `data` | `ImageFieldData \| MediaFile` | Image data from CMS entry |
3750
+ | `pictureClass` | `string` | CSS class on `<picture>` wrapper |
3751
+ | `hybridPath` | `string` | Path for hybrid editing |
3752
+ | `sizes` | `string` | Responsive sizes attribute |
3753
+ | `loading` | `string` | Loading strategy (default: `'lazy'`) |
3754
+
3755
+ Features:
3756
+ - Renders `<picture>` with `<source>` per image style (srcset, media queries)
3757
+ - Blur placeholder via `blurDataUrl` — CSS background fades out on load
3758
+ - Falls back to raw `<img>` for plain `MediaFile` objects
3759
+ - Spreads additional HTML img attributes
3760
+
3761
+ ### Video
3762
+
3763
+ Renders `VideoFieldData` with transcoded sources, poster, and accessibility tracks.
3764
+
3765
+ ```svelte
3766
+ <Video data={entry.video} controls />
3767
+ ```
3768
+
3769
+ | Prop | Type | Description |
3770
+ |------|------|-------------|
3771
+ | `data` | `VideoFieldData` | Video data from CMS entry |
3772
+ | `hybridPath` | `string` | Path for hybrid editing |
3773
+
3774
+ Features:
3775
+ - Serves transcoded sources sorted by `preferMp4` context (mp4 first for Safari, webm first otherwise)
3776
+ - Original file as final `<source>` fallback
3777
+ - `<track kind="captions">` for transcript files
3778
+ - `<track kind="descriptions">` for audio description files
3779
+ - Poster image from `data.data.posterUrl`
3780
+ - Spreads additional HTML video attributes
3781
+
3782
+ ### Media
3783
+
3784
+ Auto-detects image vs video and delegates to `<Image>` or `<Video>`.
3785
+
3786
+ ```svelte
3787
+ <Media data={entry.hero} />
3788
+ ```
3789
+
3790
+ ### StructuredContent
3791
+
3792
+ Renders `StructuredContentDoc` (content field output) to HTML. Handles all node types: paragraphs, headings, lists, tables, figures, videos, code blocks, inline blocks.
3793
+
3794
+ ```svelte
3795
+ <StructuredContent doc={entry.body} class="prose" />
3796
+ ```
3797
+
3798
+ #### Snippet Overrides
3799
+
3800
+ Override rendering for specific node types using Svelte 5 snippets:
3801
+
3802
+ ```svelte
3803
+ <StructuredContent doc={entry.body}>
3804
+ {#snippet inlineBlock(blockType, blockData, blockId)}
3805
+ {#if blockType === 'callout'}
3806
+ <aside class="callout callout-{blockData.variant}">{blockData.text}</aside>
3807
+ {/if}
3808
+ {/snippet}
3809
+
3810
+ {#snippet figure(attrs)}
3811
+ <figure class="my-figure">
3812
+ <img src={attrs.src} alt={attrs.alt} />
3813
+ {#if attrs.caption}<figcaption>{attrs.caption}</figcaption>{/if}
3814
+ </figure>
3815
+ {/snippet}
3816
+
3817
+ {#snippet heading(level, children)}
3818
+ <svelte:element this="h{level}" class="heading-{level}">
3819
+ {@render children()}
3820
+ </svelte:element>
3821
+ {/snippet}
3822
+
3823
+ {#snippet codeBlock(language, text)}
3824
+ <pre class="code-block"><code class="language-{language}">{text}</code></pre>
3825
+ {/snippet}
3826
+ </StructuredContent>
3827
+ ```
3828
+
3829
+ | Snippet | Arguments | Description |
3830
+ |---------|-----------|-------------|
3831
+ | `inlineBlock` | `blockType: string, blockData: Record, blockId: string` | Custom inline blocks |
3832
+ | `figure` | `attrs: Record` | Image figures with caption |
3833
+ | `video` | `attrs: Record` | Video embeds in content |
3834
+ | `heading` | `level: number, children: Snippet` | Heading elements |
3835
+ | `paragraph` | `children: Snippet` | Paragraph elements |
3836
+ | `codeBlock` | `language: string \| undefined, text: string` | Code blocks |
3837
+
3838
+ ### HybridTarget
3839
+
3840
+ Wraps content for hybrid (in-place) editing. When hybrid editing is enabled, the admin panel can edit content inline on the frontend.
3841
+
3842
+ ```svelte
3843
+ <HybridTarget path="hero.title" tag="h1">
3844
+ {entry.title}
3845
+ </HybridTarget>
3846
+ ```
3847
+
3848
+ ### Preview
3849
+
3850
+ Hybrid editing preview wrapper component.
3851
+
3852
+ ```svelte
3853
+ <Preview />
3854
+ ```
3855
+
3856
+ ## Context Functions
3857
+
3858
+ ### setPreferMp4
3859
+
3860
+ Set video source ordering preference. Called automatically by `<CmsProvider>`.
3861
+
3862
+ ```typescript
3863
+ import { setPreferMp4 } from 'includio-cms/sveltekit';
3864
+ setPreferMp4(true); // mp4 first (Safari)
3865
+ ```
3866
+
3867
+ ### enableHybridEditing
3868
+
3869
+ Enable hybrid editing mode for the current page.
3870
+
3871
+ ```typescript
3872
+ import { enableHybridEditing } from 'includio-cms/sveltekit';
3873
+ enableHybridEditing();
3874
+ ```
3875
+
3876
+ ## Utility Functions
3877
+
3878
+ ### structuredToHtml
3879
+
3880
+ Convert `StructuredContentDoc` to HTML string. Useful for RSS feeds and email.
3881
+
3882
+ ```typescript
3883
+ import { structuredToHtml } from 'includio-cms/sveltekit';
3884
+
3885
+ const html = structuredToHtml(entry.body, {
3886
+ inlineBlock: (blockType, blockData) => `<div>${blockData.text}</div>`,
3887
+ baseUrl: 'https://example.com'
3888
+ });
3889
+ ```
3890
+
3891
+ ### getLink
3892
+
3893
+ Extract link URL from a URL field value.
3894
+
3895
+ ```typescript
3896
+ import { getLink } from 'includio-cms/sveltekit';
3897
+
3898
+ const href = getLink(entry.link); // string | undefined
3899
+ ```
3900
+
3901
+ ### isImageFieldData / isVideoFieldData
3902
+
3903
+ Type guards for discriminating `MediaFieldData`:
3904
+
3905
+ ```typescript
3906
+ import { isImageFieldData, isVideoFieldData } from 'includio-cms/sveltekit';
3907
+
3908
+ if (isImageFieldData(entry.hero)) {
3909
+ // entry.hero is ImageFieldData
3910
+ } else if (isVideoFieldData(entry.hero)) {
3911
+ // entry.hero is VideoFieldData
3912
+ }
3913
+ ```
3914
+
3915
+ ## Query Helpers
3916
+
3917
+ Server-side utilities for extracting data from structured content documents.
3918
+
3919
+ ```typescript
3920
+ import {
3921
+ extractBlocks, extractInlineBlocks,
3922
+ extractText, extractMediaRefs
3923
+ } from 'includio-cms/sveltekit';
3924
+ ```
3925
+
3926
+ ### extractBlocks
3927
+
3928
+ Extract all block nodes (paragraphs, headings, lists, etc.) from a document.
3929
+
3930
+ ```typescript
3931
+ const blocks = extractBlocks(doc);
3932
+ ```
3933
+
3934
+ ### extractInlineBlocks
3935
+
3936
+ Extract all inline block nodes from a document.
3937
+
3938
+ ```typescript
3939
+ const inlineBlocks = extractInlineBlocks(doc);
3940
+ // [{ blockType: 'callout', blockData: { variant: 'info', text: '...' }, blockId: '...' }]
3941
+ ```
3942
+
3943
+ ### extractText
3944
+
3945
+ Extract plain text from a document (useful for search indexing, excerpts).
3946
+
3947
+ ```typescript
3948
+ const text = extractText(doc);
3949
+ // "Hello world. This is the content..."
3950
+ ```
3951
+
3952
+ ### extractMediaRefs
3953
+
3954
+ Extract all media references (image and video IDs) from a document.
3955
+
3956
+ ```typescript
3957
+ const mediaIds = extractMediaRefs(doc);
3958
+ // ['uuid-1', 'uuid-2']
3959
+ ```
3960
+
3961
+ > **Import Path:** All frontend exports come from `includio-cms/sveltekit`. Server-only utilities (like `extractBlocks`) also work from this path but are typically used in `+page.server.ts` load functions.
3962
+
3963
+
3964
+ ---
3965
+
3966
+ # API Reference
3967
+
3968
+ Includio provides three ways to access data: remote functions (SvelteKit), REST API (external clients), and the Entity API (server-side programmatic access).
3969
+
3970
+ ## Remote Functions (SvelteKit)
3971
+
3972
+ Type-safe, RPC-style communication for SvelteKit apps.
3973
+
3974
+ For `+page.server.ts` load functions, you can also import from `includio-cms/sveltekit/server`:
3975
+
3976
+ ```typescript
3977
+ import { getEntries, getEntry, countEntries } from 'includio-cms/sveltekit/server';
3978
+ ```
3979
+
3980
+ ### Queries (Read)
3981
+
3982
+ ```typescript
3983
+ import { getEntries, getEntry, getRawEntries } from 'includio-cms/admin/remote';
3984
+
3985
+ // Get published entries
3986
+ const posts = await getEntries({
3987
+ slug: 'posts',
3988
+ status: 'published',
3989
+ language: 'en'
3990
+ });
3991
+
3992
+ // Get single entry by ID
3993
+ const post = await getEntry({ id: 'uuid-here' });
3994
+
3995
+ // Filter by exact field values
3996
+ const featured = await getEntries({
3997
+ slug: 'posts',
3998
+ dataValues: { featured: true, category: 'tech' }
3999
+ });
4000
+
4001
+ // Search in field content (LIKE query)
4002
+ const results = await getEntries({
4003
+ slug: 'posts',
4004
+ dataLike: { title: 'search term' }
4005
+ });
4006
+
4007
+ // Case-insensitive OR search
4008
+ const search = await getEntries({
4009
+ slug: 'posts',
4010
+ dataILikeOr: { title: 'svelte', description: 'svelte' }
4011
+ });
4012
+
4013
+ // Sort by data field
4014
+ const sorted = await getEntries({
4015
+ slug: 'events',
4016
+ status: 'published',
4017
+ dataOrderBy: { field: 'eventDate', direction: 'asc' }
4018
+ });
4019
+
4020
+ // Pagination
4021
+ const page = await getEntries({
4022
+ slug: 'posts',
4023
+ status: 'published',
4024
+ limit: 10,
4025
+ offset: 20,
4026
+ orderBy: { column: 'createdAt', direction: 'desc' }
4027
+ });
4028
+ ```
4029
+
4030
+ ### Commands (Write)
4031
+
4032
+ Commands mutate data and require authentication.
4033
+
4034
+ ```typescript
4035
+ import {
4036
+ createEntry,
4037
+ updateEntryVersionCommand,
4038
+ archiveEntryCommand,
4039
+ unarchiveEntryCommand,
4040
+ deleteEntryCommand
4041
+ } from 'includio-cms/admin/remote';
4042
+
4043
+ // Create entry
4044
+ await createEntry({ slug: 'posts', type: 'collection' });
4045
+
4046
+ // Save as draft
4047
+ await updateEntryVersionCommand({
4048
+ entryId: 'uuid',
4049
+ data: { title: 'Hello', content: { type: 'doc', content: [] } },
4050
+ type: 'draft'
4051
+ });
4052
+
4053
+ // Publish immediately
4054
+ await updateEntryVersionCommand({
4055
+ entryId: 'uuid',
4056
+ data: { title: 'Hello' },
4057
+ type: 'published-now'
4058
+ });
4059
+
4060
+ // Unpublish
4061
+ await updateEntryVersionCommand({
4062
+ entryId: 'uuid',
4063
+ data: { title: 'Hello' },
4064
+ type: 'cancel-published'
4065
+ });
4066
+
4067
+ // Archive / Unarchive / Delete
4068
+ await archiveEntryCommand('entry-uuid');
4069
+ await unarchiveEntryCommand('entry-uuid');
4070
+ await deleteEntryCommand('entry-uuid');
4071
+ ```
4072
+
4073
+ ### GetEntriesOptions
4074
+
4075
+ ```typescript
4076
+ interface GetEntriesOptions {
4077
+ ids?: string[];
4078
+ slug?: string;
4079
+ dataValues?: Record<string, unknown>; // Exact match
4080
+ dataLike?: Record<string, unknown>; // Partial text match (LIKE)
4081
+ dataILikeOr?: Record<string, unknown>; // Case-insensitive OR search
4082
+ language?: string;
4083
+ status?: 'draft' | 'published' | 'scheduled' | 'archived';
4084
+ orderBy?: { column: 'createdAt' | 'updatedAt' | 'sortOrder'; direction: 'asc' | 'desc' };
4085
+ dataOrderBy?: { field: string; direction: 'asc' | 'desc' };
4086
+ limit?: number;
4087
+ offset?: number;
4088
+ }
4089
+ ```
4090
+
4091
+ ### Version Types
4092
+
4093
+ | Type | Behavior |
4094
+ |------|----------|
4095
+ | `draft` | Save as draft (not published) |
4096
+ | `published-now` | Publish immediately |
4097
+ | `cancel-published` | Unpublish current version |
4098
+
4099
+ ## REST API
4100
+
4101
+ For external clients. Requires API key authentication.
4102
+
4103
+ ### Authentication
4104
+
4105
+ Send your API key in the request header:
4106
+
4107
+ ```
4108
+ Authorization: Bearer YOUR_API_KEY
4109
+ ```
4110
+
4111
+ Configure API keys in your CMS config:
4112
+
4113
+ ```typescript
4114
+ defineConfig({
4115
+ apiKeys: [
4116
+ { key: process.env.API_KEY, name: 'My App', role: 'editor' }
4117
+ ]
4118
+ });
4119
+ ```
4120
+
4121
+ ### Endpoints
4122
+
4123
+ All REST endpoints are prefixed with your admin API path (e.g. `/admin/api/rest/`).
4124
+
4125
+ #### Schema
4126
+
4127
+ | Method | Path | Description |
4128
+ |--------|------|-------------|
4129
+ | `GET` | `/schema` | Full CMS schema |
4130
+ | `GET` | `/schema/:slug` | Schema for specific collection/single |
4131
+ | `GET` | `/languages` | Available languages |
4132
+
4133
+ #### Collections
4134
+
4135
+ | Method | Path | Description |
4136
+ |--------|------|-------------|
4137
+ | `GET` | `/collections/:slug` | List entries (query: `lang`, `status`, `limit`, `offset`) |
4138
+ | `GET` | `/collections/:slug/:id` | Get single entry |
4139
+ | `POST` | `/collections/:slug` | Create entry |
4140
+ | `PUT` | `/collections/:slug/:id` | Update entry draft |
4141
+ | `DELETE` | `/collections/:slug/:id` | Delete entry |
4142
+
4143
+ #### Singletons
4144
+
4145
+ | Method | Path | Description |
4146
+ |--------|------|-------------|
4147
+ | `GET` | `/singletons/:slug` | Get singleton |
4148
+ | `PUT` | `/singletons/:slug` | Update singleton |
4149
+
4150
+ #### Entry Lifecycle
4151
+
4152
+ | Method | Path | Description |
4153
+ |--------|------|-------------|
4154
+ | `POST` | `/entries/:id/publish` | Publish (query: `lang`) |
4155
+ | `POST` | `/entries/:id/unpublish` | Unpublish |
4156
+ | `POST` | `/entries/:id/archive` | Archive |
4157
+ | `POST` | `/entries/:id/unarchive` | Unarchive |
4158
+
4159
+ #### Media
4160
+
4161
+ | Method | Path | Description |
4162
+ |--------|------|-------------|
4163
+ | `POST` | `/upload` | Upload file |
4164
+ | `GET` | `/media/:id` | Get media file metadata |
4165
+
4166
+ ## Entity API
4167
+
4168
+ Server-side programmatic CRUD — for scripts, migrations, seeds, webhooks.
4169
+
4170
+ ```typescript
4171
+ import { createEntityAPI } from 'includio-cms/entity';
4172
+ import { getCMS } from 'includio-cms/core';
4173
+
4174
+ const cms = getCMS();
4175
+ const api = createEntityAPI(cms, { userId: 'system' });
4176
+
4177
+ // Create a draft
4178
+ const entry = await api.create('posts', {
4179
+ title: 'Hello World',
4180
+ content: { type: 'doc', content: [] }
4181
+ });
4182
+
4183
+ // Create and publish in one step
4184
+ const published = await api.createAndPublish('posts', {
4185
+ title: 'Published Post'
4186
+ });
4187
+
4188
+ // Update draft
4189
+ await api.update(entry.id, { title: 'Updated Title' });
4190
+
4191
+ // Publish / unpublish
4192
+ await api.publish(entry.id, 'en');
4193
+ await api.unpublish(entry.id, 'en');
4194
+
4195
+ // Archive / unarchive / delete
4196
+ await api.archive(entry.id);
4197
+ await api.unarchive(entry.id);
4198
+ await api.delete(entry.id);
4199
+
4200
+ // List entries
4201
+ const entries = await api.list('posts');
4202
+ ```
4203
+
4204
+ ### Entity API Methods
4205
+
4206
+ | Method | Arguments | Returns | Description |
4207
+ |--------|-----------|---------|-------------|
4208
+ | `create` | `slug, data?, options?` | `DbEntry` | Create draft entry |
4209
+ | `createAndPublish` | `slug, data, options?` | `DbEntry` | Create and publish |
4210
+ | `update` | `entryId, data, options?` | `DbEntryVersion` | Update draft |
4211
+ | `publish` | `entryId, lang?` | `void` | Publish latest draft |
4212
+ | `unpublish` | `entryId, lang?` | `void` | Unpublish all versions |
4213
+ | `archive` | `entryId` | `void` | Soft-delete |
4214
+ | `unarchive` | `entryId` | `void` | Restore |
4215
+ | `delete` | `entryId` | `void` | Permanently delete |
4216
+ | `list` | `slug, options?` | `RawEntry[]` | List entries |
4217
+
4218
+ Options for `create`/`createAndPublish`: `{ skipValidation?, sortOrder?, lang? }`
4219
+
4220
+ > **Delete Restrictions:** Only archived entries can be permanently deleted. Call `archive` first, then `delete`.
4221
+
4222
+
4223
+ ---
4224
+
4225
+ # Migration Guide
4226
+
4227
+ Version-by-version upgrade guide. Each section lists what changed, what to do, and new features to adopt.
4228
+
4229
+ > **For AI assistants:** When upgrading a site, read the relevant version sections below. Adopt new features (layout DSL, structured content, video transcoding) proactively — even if existing code still works, the new APIs provide better UX and performance.
4230
+
4231
+ ## General Upgrade Workflow
4232
+
4233
+ 1. Check breaking changes for all versions between your current and target
4234
+ 2. Run SQL migrations in order
4235
+ 3. Update config for new features
4236
+ 4. Adopt new field types, components, and APIs
4237
+ 5. Run `pnpm db:push` for schema sync
4238
+ 6. Test thoroughly
4239
+
4240
+ ---
4241
+
4242
+ ## New in 0.14.0 — Video Transcoding
4243
+
4244
+ **Added:** Auto-transcode videos to mp4/webm, system info, disk usage.
4245
+
4246
+ **What to do:**
4247
+ - Run the video_styles SQL migration (see [Video Transcoding](/docs/video-transcoding))
4248
+ - Ensure ffmpeg with libx264 + libvpx-vp9 is available in production
4249
+ - Config is optional — transcoding enables automatically if ffmpeg available
4250
+
4251
+ **Adopt:** Replace manual `<video>` tags with the `<Video>` component:
4252
+
4253
+ ```svelte
4254
+ <!-- Before -->
4255
+ <video src={entry.video.data.url} controls />
4256
+
4257
+ <!-- After -->
4258
+ <Video data={entry.video} controls />
4259
+ ```
4260
+
4261
+ Set up `CmsProvider` + `preferMp4` for Safari-optimized delivery. See [Video Transcoding](/docs/video-transcoding).
4262
+
4263
+ **New config option:**
4264
+
4265
+ ```typescript
4266
+ media: {
4267
+ video: {
4268
+ transcode: true,
4269
+ formats: ['mp4', 'webm'],
4270
+ maxResolution: 1080,
4271
+ crf: { mp4: 23, webm: 30 },
4272
+ concurrency: 1
4273
+ }
4274
+ }
4275
+ ```
4276
+
4277
+ ---
4278
+
4279
+ ## New in 0.13.0–0.13.4 — Security & Polish
4280
+
4281
+ **0.13.0:** Private file uploads for form submissions.
4282
+
4283
+ **0.13.2:** Configurable upload size limit via `BODY_SIZE_LIMIT` env var (default 50M, supports K/M/G suffixes).
4284
+
4285
+ **0.13.3 — Breaking:** CMS config with empty `languages` array now throws at init. Ensure at least one language.
4286
+
4287
+ **0.13.3:** Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy), timing-safe API key comparison, MIME blocklist.
4288
+
4289
+ **0.13.4:** Batch video poster regeneration, filename Unicode normalization (NFC).
4290
+
4291
+ ---
4292
+
4293
+ ## New in 0.12.0 — File Field, dataOrderBy, _url
4294
+
4295
+ **Added:**
4296
+ - `file` field type — direct file upload (not media library)
4297
+ - `dataOrderBy` — sort entries by JSON data fields
4298
+ - `_url` auto-population from `pathTemplate`
4299
+
4300
+ **Adopt:** If you sort entries in frontend queries, use `dataOrderBy`:
4301
+
4302
+ ```typescript
4303
+ const events = await getEntries({
4304
+ slug: 'events',
4305
+ status: 'published',
4306
+ dataOrderBy: { field: 'eventDate', direction: 'asc' }
4307
+ });
4308
+ ```
4309
+
4310
+ **Adopt:** Set `pathTemplate` on collections to get auto-populated `_url`:
4311
+
4312
+ ```typescript
4313
+ defineCollection({
4314
+ slug: 'posts',
4315
+ pathTemplate: 'blog/{slug}',
4316
+ // ...
4317
+ });
4318
+ // entry._url → '/blog/my-post'
4319
+ ```
4320
+
4321
+ ---
4322
+
4323
+ ## New in 0.11.0 — Codegen Overhaul, REST API
4324
+
4325
+ **Added:**
4326
+ - Flat entry types in codegen, inline block types, `countEntries` helper
4327
+ - REST API catch-all handler, file upload endpoint, schema endpoints
4328
+
4329
+ **Adopt:** Use generated types for type-safe frontend code. See [Code Generation](/docs/codegen).
4330
+
4331
+ ---
4332
+
4333
+ ## New in 0.10.0 — Field Type Consolidation
4334
+
4335
+ **Breaking:**
4336
+ - Removed `image` field → use `media` with `accept: 'image/*'`
4337
+ - Removed `richtext` field → use `content` (structured JSON)
4338
+
4339
+ **What to do:**
4340
+
4341
+ ```typescript
4342
+ // Before
4343
+ { type: 'image', slug: 'cover' }
4344
+ { type: 'richtext', slug: 'body' }
4345
+
4346
+ // After
4347
+ { type: 'media', slug: 'cover', accept: 'image/*' }
4348
+ { type: 'content', slug: 'body' }
4349
+ ```
4350
+
4351
+ Existing stored data (media UUIDs, ProseMirror docs) remains compatible — only config changes needed.
4352
+
4353
+ ---
4354
+
4355
+ ## New in 0.9.0 — Per-Language Entry Versions
4356
+
4357
+ **Breaking — major restructure:**
4358
+ - Each language gets its own `entry_version` with flat (non-localized) data
4359
+ - Publish/unpublish each language independently
4360
+ - `entry` table: removed `published_at`, `published_version_id`, `published_by`, `available_locales`
4361
+
4362
+ **SQL migration required** (run in order):
4363
+
4364
+ ```sql
4365
+ ALTER TABLE entry_version ADD COLUMN lang TEXT;
4366
+ -- Run scripts/migrate-lang-versions.ts to split multi-lang data
4367
+ ALTER TABLE entry_version ALTER COLUMN lang SET NOT NULL;
4368
+ CREATE INDEX idx_entry_version_publish ON entry_version(entry_id, lang, published_at);
4369
+ ALTER TABLE entry DROP COLUMN IF EXISTS published_at;
4370
+ ALTER TABLE entry DROP COLUMN IF EXISTS published_version_id;
4371
+ ALTER TABLE entry DROP COLUMN IF EXISTS published_by;
4372
+ ALTER TABLE entry DROP COLUMN IF EXISTS available_locales;
4373
+ ```
4374
+
4375
+ **API changes:**
4376
+ - `unpublishEntry` → `unpublishEntryLang` (requires `lang` parameter)
4377
+ - `upsertDraftVersion` and `pruneOldDraftVersions` require `lang`
4378
+ - `translateObject` removed — data is single-language per version
4379
+
4380
+ ---
4381
+
4382
+ ## New in 0.8.0 — Flat Entry Format
4383
+
4384
+ **Breaking:**
4385
+ - Object fields: `{ slug, data: {...} }` → `{ _slug, ...fields }` (flat)
4386
+ - Blocks fields: `{ _id, slug, data: {...} }` → `{ _id, _slug, ...fields }` (flat)
4387
+ - `FlatEntry` type removed — use `Entry`
4388
+ - `flattenEntry`/`flattenFields` removed — data is already flat
4389
+
4390
+ **What to do:** Run `includio update` to migrate existing entries.
4391
+
4392
+ **Adopt:** Update frontend code accessing object/blocks data:
4393
+
4394
+ ```typescript
4395
+ // Before
4396
+ const companyName = entry.companyInfo.data.name;
4397
+ const blockSlug = entry.blocks[0].slug;
4398
+
4399
+ // After
4400
+ const companyName = entry.companyInfo.name;
4401
+ const blockSlug = entry.blocks[0]._slug;
4402
+ ```
4403
+
4404
+ ---
4405
+
4406
+ ## Adopting the Layout DSL
4407
+
4408
+ If you haven't adopted the layout system yet, consider adding it to your collections. It dramatically improves the admin editing experience.
4409
+
4410
+ **Before** (flat field list):
4411
+ ```typescript
4412
+ defineCollection({
4413
+ slug: 'page',
4414
+ fields: [/* 15 fields in a long flat list */]
4415
+ });
4416
+ ```
4417
+
4418
+ **After** (organized with layout DSL):
4419
+ ```typescript
4420
+ defineCollection({
4421
+ slug: 'page',
4422
+ fields: [/* same fields */],
4423
+ layout: [
4424
+ {
4425
+ type: 'columns',
4426
+ ratio: '2fr 1fr',
4427
+ children: [
4428
+ {
4429
+ type: 'stack',
4430
+ fields: ['title', 'content']
4431
+ },
4432
+ {
4433
+ type: 'stack',
4434
+ children: [
4435
+ { type: 'card', label: { en: 'SEO', pl: 'SEO' }, fields: ['seo'] },
4436
+ { type: 'card', label: { en: 'Settings', pl: 'Ustawienia' }, fields: ['category', 'featured'] }
4437
+ ]
4438
+ }
4439
+ ]
4440
+ }
4441
+ ]
4442
+ });
4443
+ ```
4444
+
4445
+ Features:
4446
+ - Node types: `section`, `columns`, `card`, `accordion`, `stack`
4447
+ - Dot-notation for object fields: `'companyInfo.name'`
4448
+ - Preset shortcuts: `'sidebar-right'`, `'two-column'`
4449
+ - Fields not in layout render at the bottom automatically
4450
+
4451
+ See [Layout](/docs/getting-started/layout) for full documentation.
4452
+
4453
+ ---
4454
+
4455
+ ## Adopting StructuredContent Component
4456
+
4457
+ If you manually render content field JSON, switch to the built-in component:
4458
+
4459
+ ```svelte
4460
+ <!-- Before: manual recursive rendering -->
4461
+ {#if node.type === 'paragraph'}
4462
+ <p>...</p>
4463
+ {/if}
4464
+
4465
+ <!-- After -->
4466
+ <StructuredContent doc={entry.body} class="prose" />
4467
+ ```
4468
+
4469
+ Override specific node types with snippets. See [Frontend Rendering](/docs/frontend).
4470
+
4471
+
4472
+ ---
4473
+
4474
+ # Code Generation
4475
+
4476
+ Includio generates TypeScript types and Zod validation schemas from your content schema. Added in **v0.11.0**.
4477
+
4478
+ ## What Gets Generated
4479
+
4480
+ Based on your `collections`, `singles`, and `forms` config, the generator outputs:
4481
+
4482
+ - **TypeScript interfaces** per collection/single — with all field types resolved
4483
+ - **Flat entry types** — with `_id`, `_slug`, `_type`, `_url`, `_publishedAt` metadata
4484
+ - **Inline block types** — from content field `inlineBlocks` definitions
4485
+ - **Zod schemas** — for runtime validation matching your field config
4486
+ - **`countEntries` helper** — typed query wrapper
4487
+
4488
+ ## Using Generated Types
4489
+
4490
+ ```typescript
4491
+ import type { PostEntry, TeamMemberEntry } from '$cms/runtime';
4492
+
4493
+ // In +page.server.ts
4494
+ export async function load() {
4495
+ const posts: PostEntry[] = await getEntries({
4496
+ slug: 'posts',
4497
+ status: 'published'
4498
+ });
4499
+ return { posts };
4500
+ }
4501
+ ```
4502
+
4503
+ ## Flat vs Nested Types
4504
+
4505
+ Entry types are **flat** (since v0.8.0):
4506
+
4507
+ ```typescript
4508
+ // Generated PostEntry
4509
+ interface PostEntry {
4510
+ _id: string;
4511
+ _slug: string;
4512
+ _type: 'collection';
4513
+ _url?: string;
4514
+ _publishedAt?: Date | null;
4515
+ _sortOrder?: number; // if orderable
4516
+ title: string;
4517
+ content: StructuredContentDoc;
4518
+ cover: ImageFieldData;
4519
+ category: string;
4520
+ seo: { title: string; description: string; slug: string };
4521
+ }
4522
+ ```
4523
+
4524
+ Object fields are inlined. Blocks use `_slug` discriminator:
4525
+
4526
+ ```typescript
4527
+ // blocks field with multiple block types
4528
+ type ContentBlock =
4529
+ | { _id: string; _slug: 'hero'; title: string; image: ImageFieldData }
4530
+ | { _id: string; _slug: 'text'; body: StructuredContentDoc };
4531
+ ```
4532
+
4533
+ ## Hyphenated Collection Slugs
4534
+
4535
+ Collections with hyphens (e.g. `'blog-post'`) generate PascalCase types: `BlogPostEntry`.
4536
+
4537
+ > **Regenerate:** Types are regenerated automatically during development. For production, ensure codegen runs as part of your build step.
4538
+
4539
+
4540
+ ---