@webhouse/cms 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,822 @@
1
+ # @webhouse/cms — Claude Code Reference
2
+
3
+ ## What is @webhouse/cms
4
+
5
+ `@webhouse/cms` is a file-based, AI-native CMS engine for TypeScript projects. You define collections and fields in a `cms.config.ts` file, and the CMS stores content as flat JSON files in a `content/` directory (one file per document, organized by collection). It provides a REST API server, a static site builder, AI content generation via `@webhouse/cms-ai`, and a visual admin UI at [webhouse.app](https://webhouse.app). The primary use case is powering Next.js websites where content is read directly from JSON files at build time or runtime.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ # Scaffold a new project
11
+ npm create @webhouse/cms my-site
12
+
13
+ # Or with the CLI directly
14
+ npx @webhouse/cms-cli init my-site
15
+ ```
16
+
17
+ This generates:
18
+ ```
19
+ my-site/
20
+ cms.config.ts # Collection + field definitions
21
+ package.json # Dependencies: @webhouse/cms, @webhouse/cms-cli, @webhouse/cms-ai
22
+ .env # AI provider keys (ANTHROPIC_API_KEY or OPENAI_API_KEY)
23
+ content/
24
+ posts/
25
+ hello-world.json # Example document
26
+ ```
27
+
28
+ Then:
29
+ ```bash
30
+ cd my-site
31
+ npm install
32
+ npx cms dev # Start dev server + admin UI
33
+ npx cms build # Build static site
34
+ ```
35
+
36
+ ## cms.config.ts Reference
37
+
38
+ The config file uses helper functions for type safety. All are identity functions that return their input:
39
+
40
+ ```typescript
41
+ import { defineConfig, defineCollection, defineBlock, defineField } from '@webhouse/cms';
42
+
43
+ export default defineConfig({
44
+ collections: [ /* ... */ ],
45
+ blocks: [ /* ... */ ],
46
+ defaultLocale: 'en', // Optional: default locale for <html lang="">
47
+ locales: ['en', 'da'], // Optional: supported locales for AI translation
48
+ autolinks: [ /* ... */ ], // Optional: automatic internal linking rules
49
+ storage: { /* ... */ }, // Optional: storage adapter config
50
+ build: { outDir: 'dist', baseUrl: '/' },
51
+ api: { port: 3000 },
52
+ });
53
+ ```
54
+
55
+ ### Collection Config
56
+
57
+ ```typescript
58
+ defineCollection({
59
+ name: 'posts', // Required: unique identifier, used as directory name
60
+ label: 'Blog Posts', // Optional: human-readable label for admin UI
61
+ slug: 'posts', // Optional: URL slug override
62
+ urlPrefix: '/blog', // Optional: URL prefix for generated pages
63
+ sourceLocale: 'en', // Optional: primary authoring locale
64
+ locales: ['en', 'da'], // Optional: translatable locales
65
+ fields: [ /* ... */ ], // Required: array of FieldConfig
66
+ hooks: { // Optional: lifecycle hooks
67
+ beforeCreate: 'path/to/hook.js',
68
+ afterCreate: 'path/to/hook.js',
69
+ beforeUpdate: 'path/to/hook.js',
70
+ afterUpdate: 'path/to/hook.js',
71
+ beforeDelete: 'path/to/hook.js',
72
+ afterDelete: 'path/to/hook.js',
73
+ },
74
+ })
75
+ ```
76
+
77
+ ### Complete Field Type Reference
78
+
79
+ Every field has these common properties:
80
+ ```typescript
81
+ {
82
+ name: string; // Required: field key in the document data object
83
+ type: FieldType; // Required: one of the types below
84
+ label?: string; // Optional: human-readable label for admin UI
85
+ required?: boolean; // Optional: whether field must have a value
86
+ defaultValue?: unknown; // Optional: default value
87
+ ai?: { // Optional: hints for AI content generation
88
+ hint?: string; // Instruction for the AI, e.g. "Write in a friendly tone"
89
+ maxLength?: number; // Maximum character count for AI output
90
+ tone?: string; // Tone instruction, e.g. "professional", "casual"
91
+ };
92
+ aiLock?: { // Optional: AI lock behavior
93
+ autoLockOnEdit?: boolean; // Lock field when user edits it (default: true)
94
+ lockable?: boolean; // Whether field can be locked at all (default: true)
95
+ requireApproval?: boolean; // Require human approval before AI can write
96
+ };
97
+ }
98
+ ```
99
+
100
+ #### text
101
+ Single-line text input.
102
+ ```typescript
103
+ { name: 'title', type: 'text', label: 'Title', required: true, maxLength: 120, minLength: 3 }
104
+ ```
105
+
106
+ #### textarea
107
+ Multi-line plain text.
108
+ ```typescript
109
+ { name: 'excerpt', type: 'textarea', label: 'Excerpt', maxLength: 300 }
110
+ ```
111
+
112
+ #### richtext
113
+ Rich text / Markdown content. Rendered as a block editor in the admin UI.
114
+ ```typescript
115
+ { name: 'content', type: 'richtext', label: 'Content' }
116
+ ```
117
+
118
+ #### number
119
+ Numeric value.
120
+ ```typescript
121
+ { name: 'price', type: 'number', label: 'Price' }
122
+ ```
123
+
124
+ #### boolean
125
+ True/false toggle.
126
+ ```typescript
127
+ { name: 'featured', type: 'boolean', label: 'Featured' }
128
+ ```
129
+
130
+ #### date
131
+ ISO date string.
132
+ ```typescript
133
+ { name: 'publishDate', type: 'date', label: 'Publish Date' }
134
+ ```
135
+
136
+ #### image
137
+ Single image reference (URL or path).
138
+ ```typescript
139
+ { name: 'heroImage', type: 'image', label: 'Hero Image' }
140
+ ```
141
+
142
+ #### image-gallery
143
+ Multiple images.
144
+ ```typescript
145
+ { name: 'photos', type: 'image-gallery', label: 'Photo Gallery' }
146
+ ```
147
+
148
+ #### video
149
+ Video reference (URL or embed).
150
+ ```typescript
151
+ { name: 'intro', type: 'video', label: 'Intro Video' }
152
+ ```
153
+
154
+ #### select
155
+ Dropdown selection from predefined options. Requires `options` array.
156
+ ```typescript
157
+ {
158
+ name: 'category',
159
+ type: 'select',
160
+ label: 'Category',
161
+ options: [
162
+ { label: 'Web Development', value: 'web' },
163
+ { label: 'Mobile App', value: 'mobile' },
164
+ { label: 'AI Tools', value: 'ai' },
165
+ ],
166
+ }
167
+ ```
168
+
169
+ #### tags
170
+ Free-form tag input. Stored as `string[]`.
171
+ ```typescript
172
+ { name: 'tags', type: 'tags', label: 'Tags' }
173
+ ```
174
+
175
+ #### relation
176
+ Reference to documents in another collection. Set `multiple: true` for many-to-many.
177
+ ```typescript
178
+ { name: 'author', type: 'relation', collection: 'team', label: 'Author' }
179
+ { name: 'relatedPosts', type: 'relation', collection: 'posts', multiple: true, label: 'Related Posts' }
180
+ ```
181
+
182
+ #### array
183
+ Repeatable list of sub-fields. Each item is an object with the defined fields. If `fields` is omitted, it stores a plain `string[]`.
184
+ ```typescript
185
+ {
186
+ name: 'bullets',
187
+ type: 'array',
188
+ label: 'Bullet Points',
189
+ // No fields = string array
190
+ }
191
+
192
+ {
193
+ name: 'stats',
194
+ type: 'array',
195
+ label: 'Stats',
196
+ fields: [
197
+ { name: 'value', type: 'text', label: 'Value' },
198
+ { name: 'label', type: 'text', label: 'Label' },
199
+ ],
200
+ }
201
+ ```
202
+
203
+ #### object
204
+ A nested group of fields. Stored as a single object.
205
+ ```typescript
206
+ {
207
+ name: 'dropdown',
208
+ type: 'object',
209
+ label: 'Dropdown Menu',
210
+ fields: [
211
+ { name: 'type', type: 'select', options: [
212
+ { label: 'List', value: 'list' },
213
+ { label: 'Columns', value: 'columns' },
214
+ ]},
215
+ { name: 'sections', type: 'array', label: 'Sections', fields: [
216
+ { name: 'heading', type: 'text' },
217
+ { name: 'links', type: 'array', fields: [
218
+ { name: 'label', type: 'text' },
219
+ { name: 'href', type: 'text' },
220
+ { name: 'external', type: 'boolean' },
221
+ ]},
222
+ ]},
223
+ ],
224
+ }
225
+ ```
226
+
227
+ #### blocks
228
+ Dynamic content sections using the block system. Stored as an array of block objects, each with a `_block` discriminator field.
229
+ ```typescript
230
+ {
231
+ name: 'sections',
232
+ type: 'blocks',
233
+ label: 'Page Sections',
234
+ blocks: ['hero', 'features', 'cta'], // References block names defined in config.blocks
235
+ }
236
+ ```
237
+
238
+ ## Block System
239
+
240
+ Blocks are reusable content structures used within `blocks`-type fields. Define them at the top level of your config:
241
+
242
+ ```typescript
243
+ export default defineConfig({
244
+ blocks: [
245
+ defineBlock({
246
+ name: 'hero', // Unique block identifier
247
+ label: 'Hero Section', // Human-readable label
248
+ fields: [
249
+ { name: 'tagline', type: 'text', label: 'Tagline' },
250
+ { name: 'description', type: 'textarea' },
251
+ { name: 'ctaText', type: 'text', label: 'CTA Text' },
252
+ { name: 'ctaUrl', type: 'text', label: 'CTA URL' },
253
+ ],
254
+ }),
255
+ defineBlock({
256
+ name: 'features',
257
+ label: 'Features Grid',
258
+ fields: [
259
+ { name: 'title', type: 'text' },
260
+ { name: 'items', type: 'array', fields: [
261
+ { name: 'icon', type: 'text' },
262
+ { name: 'title', type: 'text' },
263
+ { name: 'description', type: 'textarea' },
264
+ ]},
265
+ ],
266
+ }),
267
+ ],
268
+ collections: [
269
+ defineCollection({
270
+ name: 'pages',
271
+ fields: [
272
+ { name: 'title', type: 'text', required: true },
273
+ { name: 'sections', type: 'blocks', blocks: ['hero', 'features'] },
274
+ ],
275
+ }),
276
+ ],
277
+ });
278
+ ```
279
+
280
+ In the stored JSON, each block item includes a `_block` discriminator:
281
+ ```json
282
+ {
283
+ "data": {
284
+ "sections": [
285
+ { "_block": "hero", "tagline": "Build faster", "ctaText": "Get Started" },
286
+ { "_block": "features", "title": "Why Us", "items": [ /* ... */ ] }
287
+ ]
288
+ }
289
+ }
290
+ ```
291
+
292
+ When rendering, use `_block` to determine which component to render:
293
+ ```typescript
294
+ function renderSection(block: Record<string, unknown>) {
295
+ switch (block._block) {
296
+ case 'hero': return <Hero tagline={block.tagline as string} />;
297
+ case 'features': return <Features items={block.items as Item[]} />;
298
+ }
299
+ }
300
+ ```
301
+
302
+ ## Storage Adapters
303
+
304
+ ### Filesystem (default)
305
+ Stores documents as JSON files in `content/<collection>/<slug>.json`. Best for Git-based workflows.
306
+
307
+ ```typescript
308
+ storage: {
309
+ adapter: 'filesystem',
310
+ filesystem: { contentDir: 'content' }, // Default: 'content'
311
+ }
312
+ ```
313
+
314
+ ### GitHub
315
+ Reads and writes JSON files directly via the GitHub API. Each create/update/delete is a commit.
316
+
317
+ ```typescript
318
+ storage: {
319
+ adapter: 'github',
320
+ github: {
321
+ owner: 'your-org',
322
+ repo: 'your-repo',
323
+ branch: 'main', // Default: 'main'
324
+ contentDir: 'content', // Default: 'content'
325
+ token: process.env.GITHUB_TOKEN!,
326
+ },
327
+ }
328
+ ```
329
+
330
+ ### SQLite
331
+ Stores documents in a local SQLite database. Useful for API-heavy use cases.
332
+
333
+ ```typescript
334
+ storage: {
335
+ adapter: 'sqlite',
336
+ sqlite: { path: './data/cms.db' }, // Optional, has a default path
337
+ }
338
+ ```
339
+
340
+ ## Content Structure
341
+
342
+ Every document is stored as a JSON file at `content/<collection>/<slug>.json` with this shape:
343
+
344
+ ```typescript
345
+ interface Document {
346
+ id: string; // Unique ID (generated, e.g. "a1b2c3d4")
347
+ slug: string; // URL-safe identifier, used as filename
348
+ collection: string; // Collection name
349
+ status: 'draft' | 'published' | 'archived';
350
+ data: Record<string, unknown>; // All field values live here
351
+ _fieldMeta: Record<string, { // Per-field metadata (AI provenance, locks)
352
+ lockedBy?: 'user' | 'ai' | 'import';
353
+ lockedAt?: string;
354
+ aiGenerated?: boolean;
355
+ aiModel?: string;
356
+ }>;
357
+ createdAt: string; // ISO timestamp
358
+ updatedAt: string; // ISO timestamp
359
+ locale?: string; // BCP 47 locale tag, e.g. "en", "da"
360
+ translationOf?: string; // Slug of source document (for translations)
361
+ publishAt?: string; // ISO timestamp for scheduled publishing
362
+ }
363
+ ```
364
+
365
+ Example file `content/posts/hello-world.json`:
366
+ ```json
367
+ {
368
+ "id": "abc123",
369
+ "slug": "hello-world",
370
+ "collection": "posts",
371
+ "status": "published",
372
+ "data": {
373
+ "title": "Hello, World!",
374
+ "excerpt": "My first post.",
375
+ "content": "# Hello\n\nWelcome to my blog.",
376
+ "date": "2025-01-15T10:00:00.000Z",
377
+ "tags": ["intro", "welcome"]
378
+ },
379
+ "_fieldMeta": {},
380
+ "createdAt": "2025-01-15T10:00:00.000Z",
381
+ "updatedAt": "2025-01-15T10:00:00.000Z"
382
+ }
383
+ ```
384
+
385
+ ## Reading Content in Next.js
386
+
387
+ Content is stored as flat JSON files. Read them directly with `fs` — no SDK client needed.
388
+
389
+ ### Loader Functions
390
+
391
+ ```typescript
392
+ // lib/content.ts
393
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
394
+ import { join } from 'node:path';
395
+
396
+ const CONTENT_DIR = join(process.cwd(), 'content');
397
+
398
+ interface Document<T = Record<string, unknown>> {
399
+ id: string;
400
+ slug: string;
401
+ collection: string;
402
+ status: 'draft' | 'published' | 'archived';
403
+ data: T;
404
+ createdAt: string;
405
+ updatedAt: string;
406
+ }
407
+
408
+ /** Get all documents in a collection */
409
+ export function getCollection<T = Record<string, unknown>>(
410
+ collection: string,
411
+ status: 'published' | 'draft' | 'all' = 'published'
412
+ ): Document<T>[] {
413
+ const dir = join(CONTENT_DIR, collection);
414
+ if (!existsSync(dir)) return [];
415
+
416
+ return readdirSync(dir)
417
+ .filter(f => f.endsWith('.json'))
418
+ .map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')) as Document<T>)
419
+ .filter(doc => status === 'all' || doc.status === status)
420
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
421
+ }
422
+
423
+ /** Get a single document by slug */
424
+ export function getDocument<T = Record<string, unknown>>(
425
+ collection: string,
426
+ slug: string
427
+ ): Document<T> | null {
428
+ const filePath = join(CONTENT_DIR, collection, `${slug}.json`);
429
+ if (!existsSync(filePath)) return null;
430
+ return JSON.parse(readFileSync(filePath, 'utf-8')) as Document<T>;
431
+ }
432
+
433
+ /** Get a singleton document (e.g. global settings) */
434
+ export function getSingleton<T = Record<string, unknown>>(
435
+ collection: string,
436
+ slug: string = collection
437
+ ): T | null {
438
+ const doc = getDocument<T>(collection, slug);
439
+ return doc?.data ?? null;
440
+ }
441
+ ```
442
+
443
+ ### Example Next.js Page (App Router)
444
+
445
+ ```typescript
446
+ // app/blog/page.tsx
447
+ import { getCollection } from '@/lib/content';
448
+
449
+ interface Post {
450
+ title: string;
451
+ excerpt: string;
452
+ date: string;
453
+ tags: string[];
454
+ }
455
+
456
+ export default function BlogPage() {
457
+ const posts = getCollection<Post>('posts');
458
+
459
+ return (
460
+ <main>
461
+ <h1>Blog</h1>
462
+ {posts.map(post => (
463
+ <article key={post.slug}>
464
+ <a href={`/blog/${post.slug}`}>
465
+ <h2>{post.data.title}</h2>
466
+ <p>{post.data.excerpt}</p>
467
+ <time>{post.data.date}</time>
468
+ </a>
469
+ </article>
470
+ ))}
471
+ </main>
472
+ );
473
+ }
474
+ ```
475
+
476
+ ```typescript
477
+ // app/blog/[slug]/page.tsx
478
+ import { getDocument, getCollection } from '@/lib/content';
479
+ import { notFound } from 'next/navigation';
480
+
481
+ export function generateStaticParams() {
482
+ return getCollection('posts').map(p => ({ slug: p.slug }));
483
+ }
484
+
485
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
486
+ const { slug } = await params;
487
+ const post = getDocument<{ title: string; content: string }>('posts', slug);
488
+ if (!post) notFound();
489
+
490
+ return (
491
+ <article>
492
+ <h1>{post.data.title}</h1>
493
+ <div dangerouslySetInnerHTML={{ __html: post.data.content }} />
494
+ </article>
495
+ );
496
+ }
497
+ ```
498
+
499
+ ### Rendering Blocks
500
+
501
+ ```typescript
502
+ // app/[slug]/page.tsx
503
+ import { getDocument } from '@/lib/content';
504
+
505
+ interface Block { _block: string; [key: string]: unknown; }
506
+
507
+ function renderBlock(block: Block, index: number) {
508
+ switch (block._block) {
509
+ case 'hero':
510
+ return <section key={index}><h1>{block.tagline as string}</h1></section>;
511
+ case 'features':
512
+ return (
513
+ <section key={index}>
514
+ {(block.items as { title: string; description: string }[]).map((item, i) => (
515
+ <div key={i}><h3>{item.title}</h3><p>{item.description}</p></div>
516
+ ))}
517
+ </section>
518
+ );
519
+ default:
520
+ return null;
521
+ }
522
+ }
523
+
524
+ export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
525
+ const { slug } = await params;
526
+ const page = getDocument<{ title: string; sections: Block[] }>('pages', slug);
527
+ if (!page) return null;
528
+
529
+ return (
530
+ <main>
531
+ {page.data.sections?.map((block, i) => renderBlock(block, i))}
532
+ </main>
533
+ );
534
+ }
535
+ ```
536
+
537
+ ## CLI Commands
538
+
539
+ All commands are run via `npx cms <command>` (provided by `@webhouse/cms-cli`).
540
+
541
+ | Command | Description |
542
+ |---------|-------------|
543
+ | `cms init [name]` | Scaffold a new CMS project |
544
+ | `cms dev [--port 3000]` | Start dev server with hot reload |
545
+ | `cms build [--outDir dist]` | Build static site |
546
+ | `cms serve [--port 5000] [--dir dist]` | Serve the built static site |
547
+ | `cms ai generate <collection> "<prompt>"` | Generate a new document with AI |
548
+ | `cms ai rewrite <collection>/<slug> "<instruction>"` | Rewrite an existing document with AI |
549
+ | `cms ai seo [--status published]` | Run SEO optimization on all documents |
550
+ | `cms mcp keygen [--label "My key"] [--scopes "read,write"]` | Generate MCP API key |
551
+ | `cms mcp test [--endpoint url]` | Test local MCP server |
552
+ | `cms mcp status [--endpoint url]` | Check MCP server status |
553
+
554
+ ### AI Commands
555
+
556
+ AI commands require `@webhouse/cms-ai` and an `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in `.env`.
557
+
558
+ ```bash
559
+ # Generate a blog post
560
+ npx cms ai generate posts "Write a guide to TypeScript generics"
561
+
562
+ # Rewrite with instructions
563
+ npx cms ai rewrite posts/hello-world "Make it more concise and add code examples"
564
+
565
+ # SEO optimization across all published content
566
+ npx cms ai seo
567
+ ```
568
+
569
+ ## CMS Admin UI
570
+
571
+ The visual admin interface is available at [webhouse.app](https://webhouse.app) (hosted) or runs locally on port 3010 during development. It provides:
572
+
573
+ - Visual document editor for all collections
574
+ - Block editor with drag-and-drop for `blocks` fields
575
+ - Rich text editor for `richtext` fields
576
+ - Image upload and gallery management
577
+ - Relation picker for cross-collection references
578
+ - Field-level AI lock indicators
579
+ - Draft/published/archived status management
580
+ - AI content generation and rewriting from the UI
581
+
582
+ Connect your project by pointing the admin UI at your CMS API endpoint.
583
+
584
+ ## Complete cms.config.ts Example
585
+
586
+ A realistic config with multiple collections, blocks, nested arrays, relations, and i18n:
587
+
588
+ ```typescript
589
+ import { defineConfig, defineCollection, defineBlock } from '@webhouse/cms';
590
+
591
+ export default defineConfig({
592
+ defaultLocale: 'en',
593
+ locales: ['en', 'da'],
594
+
595
+ blocks: [
596
+ defineBlock({
597
+ name: 'hero',
598
+ label: 'Hero Section',
599
+ fields: [
600
+ { name: 'badge', type: 'text', label: 'Badge Text' },
601
+ { name: 'tagline', type: 'text', label: 'Tagline', required: true },
602
+ { name: 'description', type: 'textarea', label: 'Description' },
603
+ { name: 'image', type: 'image', label: 'Background Image' },
604
+ { name: 'ctas', type: 'array', label: 'Call-to-Actions', fields: [
605
+ { name: 'label', type: 'text', label: 'Label' },
606
+ { name: 'href', type: 'text', label: 'URL' },
607
+ { name: 'variant', type: 'select', options: [
608
+ { label: 'Solid', value: 'solid' },
609
+ { label: 'Outline', value: 'outline' },
610
+ ]},
611
+ ]},
612
+ ],
613
+ }),
614
+ defineBlock({
615
+ name: 'features',
616
+ label: 'Features Grid',
617
+ fields: [
618
+ { name: 'title', type: 'text', label: 'Section Title' },
619
+ { name: 'description', type: 'textarea', label: 'Section Description' },
620
+ { name: 'items', type: 'array', label: 'Feature Cards', fields: [
621
+ { name: 'icon', type: 'text', label: 'Icon' },
622
+ { name: 'title', type: 'text', label: 'Title' },
623
+ { name: 'description', type: 'textarea', label: 'Description' },
624
+ ]},
625
+ ],
626
+ }),
627
+ defineBlock({
628
+ name: 'notice',
629
+ label: 'Notice / Callout',
630
+ fields: [
631
+ { name: 'text', type: 'textarea', label: 'Text' },
632
+ { name: 'variant', type: 'select', label: 'Variant', options: [
633
+ { label: 'Info', value: 'info' },
634
+ { label: 'Warning', value: 'warning' },
635
+ { label: 'Tip', value: 'tip' },
636
+ ]},
637
+ ],
638
+ }),
639
+ defineBlock({
640
+ name: 'carousel',
641
+ label: 'Image Carousel',
642
+ fields: [
643
+ { name: 'images', type: 'image-gallery', label: 'Images' },
644
+ { name: 'caption', type: 'text', label: 'Caption' },
645
+ ],
646
+ }),
647
+ ],
648
+
649
+ autolinks: [
650
+ { term: 'TypeScript', href: '/blog/typescript', title: 'TypeScript articles' },
651
+ ],
652
+
653
+ collections: [
654
+ defineCollection({
655
+ name: 'global',
656
+ label: 'Global Settings',
657
+ fields: [
658
+ { name: 'siteTitle', type: 'text', label: 'Site Title' },
659
+ { name: 'siteDescription', type: 'textarea', label: 'Meta Description' },
660
+ { name: 'navLinks', type: 'array', label: 'Navigation', fields: [
661
+ { name: 'label', type: 'text', label: 'Label' },
662
+ { name: 'href', type: 'text', label: 'URL' },
663
+ { name: 'dropdown', type: 'object', label: 'Dropdown', fields: [
664
+ { name: 'type', type: 'select', options: [
665
+ { label: 'List', value: 'list' },
666
+ { label: 'Columns', value: 'columns' },
667
+ ]},
668
+ { name: 'sections', type: 'array', label: 'Sections', fields: [
669
+ { name: 'heading', type: 'text' },
670
+ { name: 'links', type: 'array', fields: [
671
+ { name: 'label', type: 'text' },
672
+ { name: 'href', type: 'text' },
673
+ { name: 'external', type: 'boolean' },
674
+ ]},
675
+ ]},
676
+ ]},
677
+ ]},
678
+ { name: 'footerEmail', type: 'text', label: 'Footer Email' },
679
+ ],
680
+ }),
681
+
682
+ defineCollection({
683
+ name: 'pages',
684
+ label: 'Pages',
685
+ urlPrefix: '/',
686
+ fields: [
687
+ { name: 'title', type: 'text', required: true },
688
+ { name: 'metaDescription', type: 'textarea', label: 'Meta Description' },
689
+ { name: 'sections', type: 'blocks', label: 'Sections',
690
+ blocks: ['hero', 'features', 'notice', 'carousel'] },
691
+ ],
692
+ }),
693
+
694
+ defineCollection({
695
+ name: 'posts',
696
+ label: 'Blog Posts',
697
+ urlPrefix: '/blog',
698
+ sourceLocale: 'en',
699
+ locales: ['en', 'da'],
700
+ fields: [
701
+ { name: 'title', type: 'text', required: true,
702
+ ai: { hint: 'Concise, descriptive title under 70 characters', maxLength: 70 } },
703
+ { name: 'excerpt', type: 'textarea', label: 'Excerpt',
704
+ ai: { hint: 'One-paragraph summary', maxLength: 200 } },
705
+ { name: 'content', type: 'richtext', label: 'Content' },
706
+ { name: 'date', type: 'date', label: 'Publish Date' },
707
+ { name: 'author', type: 'relation', collection: 'team', label: 'Author' },
708
+ { name: 'category', type: 'select', options: [
709
+ { label: 'Engineering', value: 'engineering' },
710
+ { label: 'Design', value: 'design' },
711
+ { label: 'Company', value: 'company' },
712
+ ]},
713
+ { name: 'tags', type: 'tags', label: 'Tags' },
714
+ { name: 'coverImage', type: 'image', label: 'Cover Image' },
715
+ { name: 'relatedPosts', type: 'relation', collection: 'posts', multiple: true,
716
+ label: 'Related Posts' },
717
+ ],
718
+ }),
719
+
720
+ defineCollection({
721
+ name: 'team',
722
+ label: 'Team Members',
723
+ fields: [
724
+ { name: 'name', type: 'text', required: true },
725
+ { name: 'role', type: 'text' },
726
+ { name: 'bio', type: 'textarea' },
727
+ { name: 'photo', type: 'image' },
728
+ { name: 'sortOrder', type: 'number' },
729
+ ],
730
+ }),
731
+
732
+ defineCollection({
733
+ name: 'work',
734
+ label: 'Case Studies',
735
+ urlPrefix: '/work',
736
+ fields: [
737
+ { name: 'title', type: 'text', required: true },
738
+ { name: 'client', type: 'text', required: true },
739
+ { name: 'category', type: 'select', options: [
740
+ { label: 'Web', value: 'web' },
741
+ { label: 'Mobile', value: 'mobile' },
742
+ { label: 'AI', value: 'ai' },
743
+ ]},
744
+ { name: 'excerpt', type: 'textarea' },
745
+ { name: 'content', type: 'richtext' },
746
+ { name: 'year', type: 'text' },
747
+ { name: 'tech', type: 'tags', label: 'Tech Stack' },
748
+ { name: 'featured', type: 'boolean' },
749
+ { name: 'gallery', type: 'image-gallery', label: 'Project Gallery' },
750
+ { name: 'demoVideo', type: 'video', label: 'Demo Video' },
751
+ ],
752
+ }),
753
+ ],
754
+
755
+ storage: {
756
+ adapter: 'filesystem',
757
+ filesystem: { contentDir: 'content' },
758
+ },
759
+
760
+ build: {
761
+ outDir: 'dist',
762
+ baseUrl: 'https://example.com',
763
+ },
764
+
765
+ api: { port: 3000 },
766
+ });
767
+ ```
768
+
769
+ ## Programmatic Usage
770
+
771
+ You can use the CMS engine programmatically (e.g. in scripts or API routes):
772
+
773
+ ```typescript
774
+ import { createCms, defineConfig, defineCollection } from '@webhouse/cms';
775
+
776
+ const config = defineConfig({
777
+ collections: [
778
+ defineCollection({ name: 'posts', fields: [
779
+ { name: 'title', type: 'text', required: true },
780
+ { name: 'content', type: 'richtext' },
781
+ ]}),
782
+ ],
783
+ storage: { adapter: 'filesystem', filesystem: { contentDir: 'content' } },
784
+ });
785
+
786
+ const cms = await createCms(config);
787
+
788
+ // Create a document
789
+ const doc = await cms.content.create('posts', {
790
+ status: 'published',
791
+ data: { title: 'Hello', content: '# Hello World' },
792
+ }, { actor: 'user' });
793
+
794
+ // Query documents
795
+ const { documents } = await cms.content.findMany('posts', {
796
+ status: 'published',
797
+ orderBy: 'createdAt',
798
+ order: 'desc',
799
+ limit: 10,
800
+ });
801
+
802
+ // Find by slug
803
+ const post = await cms.content.findBySlug('posts', 'hello');
804
+
805
+ // Update
806
+ await cms.content.update('posts', doc.id, {
807
+ data: { title: 'Updated Title' },
808
+ });
809
+
810
+ // Clean up
811
+ await cms.storage.close();
812
+ ```
813
+
814
+ ## Key Architecture Notes
815
+
816
+ - **No database required** — filesystem adapter stores everything as JSON files committed to Git
817
+ - **Document slugs are filenames** — `content/posts/my-post.json` has slug `my-post`
818
+ - **Field values live in `data`** — top-level document fields (`id`, `slug`, `status`, etc.) are system fields; user-defined field values are always inside `data`
819
+ - **Blocks use `_block` discriminator** — when iterating over a blocks field, check `item._block` to determine the block type
820
+ - **Relations store slugs or IDs** — relation fields store references to other documents, not embedded data
821
+ - **`_fieldMeta` tracks AI provenance** — when AI writes a field, metadata records which model, when, and whether the field is locked against future AI overwrites
822
+ - **Status workflow** — documents are `draft`, `published`, or `archived`. Use `publishAt` for scheduled publishing