@webhouse/cms 0.1.3 → 0.2.1
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 +822 -0
- package/dist/index.cjs +9 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|