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.
- package/CHANGELOG.md +33 -0
- package/DOCS.md +4540 -0
- package/README.md +37 -42
- package/ROADMAP.md +13 -0
- package/dist/admin/client/account/preferences-section.svelte +1 -0
- package/dist/admin/client/account/profile-section.svelte +4 -1
- package/dist/admin/client/account/security-section.svelte +2 -2
- package/dist/admin/client/entry/entry-form.svelte +1 -1
- package/dist/admin/client/users/users-page.svelte +9 -9
- package/dist/admin/components/fields/blocks-field.svelte +1 -1
- package/dist/admin/components/fields/media-field.svelte +1 -0
- package/dist/admin/components/layout/layout-renderer.svelte +2 -1
- package/dist/admin/components/layout/layout-renderer.svelte.d.ts +1 -0
- package/dist/admin/components/media/bulk-action-bar.svelte +2 -0
- package/dist/admin/components/media/file/file-details.svelte +1 -0
- package/dist/admin/components/media/focal-point-input.svelte +3 -2
- package/dist/admin/components/media/tag-sidebar.svelte +1 -0
- package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +2 -1
- package/dist/components/ui/dropdown-menu/index.d.ts +17 -0
- package/dist/components/ui/spinner/spinner.svelte +2 -2
- package/dist/components/ui/spinner/spinner.svelte.d.ts +4 -0
- package/dist/core/server/fields/utils/imageStyles.js +5 -4
- package/dist/core/server/media/styles/sharp/generateImageStyle.js +27 -0
- package/dist/db-postgres/index.js +4 -4
- package/dist/db-postgres/schema/imageStyle.d.ts +17 -0
- package/dist/db-postgres/schema/imageStyle.js +3 -2
- package/dist/demo/seed.js +0 -1
- package/dist/sveltekit/components/cms-provider.svelte +17 -0
- package/dist/sveltekit/components/cms-provider.svelte.d.ts +12 -0
- package/dist/sveltekit/components/structured-content.svelte +1 -0
- package/dist/sveltekit/components/video-context.d.ts +2 -0
- package/dist/sveltekit/components/video-context.js +8 -0
- package/dist/sveltekit/components/video.svelte +12 -4
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/server/handle.js +9 -0
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +1 -0
- package/dist/sveltekit/server/layout.d.ts +4 -0
- package/dist/sveltekit/server/layout.js +5 -0
- package/dist/types/cms-context.d.ts +3 -0
- package/dist/types/cms-context.js +1 -0
- package/dist/types/fields.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/updates/0.14.1/index.d.ts +2 -0
- package/dist/updates/0.14.1/index.js +15 -0
- package/dist/updates/0.14.2/index.d.ts +2 -0
- package/dist/updates/0.14.2/index.js +18 -0
- package/dist/updates/index.js +3 -1
- 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/{slug}'`) 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 `<Video>` 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 `<head>` 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
|
+
---
|