Content Engine
The Content Engine is Studio's write path for all content operations. It orchestrates validation, serialization, branching, and commit operations through the GitProvider interface.
Source: server/utils/content-engine/
Architecture
Content Engine
├── index.ts # Factory function + public API
├── types.ts # Shared types and constants
├── helpers.ts # Common utilities
├── save-content.ts # Collection/singleton/dictionary writes
├── save-document.ts # Document kind (markdown + frontmatter)
├── save-model.ts # Model definition writes
├── delete-content.ts # Content deletion
├── init-project.ts # Project initialization
├── update-status.ts # Status changes + locale copying
└── branch-ops.ts # Branch guard, merge, reject, listCreating an Engine Instance
The engine is created per-request with a GitProvider scoped to the target repository:
import { createContentEngine } from '~/server/utils/content-engine'
const engine = createContentEngine({
git: useGitProvider({
installationId: workspace.github_installation_id,
owner: 'org',
repo: 'my-repo',
}),
contentRoot: '', // or 'apps/web' for monorepos
projectId: 'uuid',
})Public API
interface ContentEngineAPI {
// Content CRUD
saveContent(modelId: string, locale: string, data: Record<string, unknown>,
userEmail: string, options?: { autoPublish?: boolean }): Promise<WriteResult>
saveDocument(modelId: string, locale: string, slug: string,
frontmatter: Record<string, unknown>, body: string,
userEmail: string, options?: { autoPublish?: boolean }): Promise<WriteResult>
deleteContent(modelId: string, locale: string, entryIds: string[],
userEmail: string): Promise<WriteResult>
// Model operations
saveModel(definition: ModelDefinition, userEmail: string): Promise<WriteResult>
// Status management
updateEntryStatus(modelId: string, locale: string, entryIds: string[],
status: 'draft' | 'published' | 'archived', userEmail: string): Promise<WriteResult>
copyLocale(modelId: string, from: string, to: string,
userEmail: string): Promise<WriteResult>
// Branch operations
listContentBranches(): Promise<Branch[]>
mergeBranch(branch: string): Promise<MergeResult>
rejectBranch(branch: string): Promise<void>
// Project initialization
initProject(stack: string, locales: string[], domains: string[],
models: ModelDefinition[], userEmail: string): Promise<WriteResult>
}Write Result
Every write operation returns a WriteResult:
interface WriteResult {
branch: string // Branch name (e.g., 'cr/content/blog-post/en/...')
commit: Commit // Git commit with SHA
diff: FileDiff[] // Changed files
validation: ValidationResult // Schema validation result
}Write Flow
Every content write follows this pipeline:
1. Validate Input
↓
2. Read Existing Content (from Git via brain cache)
↓
3. Merge Changes (new data into existing)
↓
4. Validate Against Schema
↓
5. Serialize to Canonical JSON
↓
6. Ensure 'contentrain' SSOT Branch Exists
↓
7. Create cr/* Feature Branch
↓
8. Commit Files to Feature Branch
↓
9. Update Meta Files (timestamps, authors)
↓
10. Update context.json (stats, last operation)
↓
11. Return WriteResultBranch Strategy
Constants
const CONTENT_BRANCH = 'contentrain' // SSOT branch
const BRANCH_PREFIX = 'cr/' // Feature branch prefixBranch Naming Convention
cr/{operation}/{modelId}/{locale}/{timestamp}-{random}Examples:
cr/content/blog-post/en/1774800862-27c1cr/model/blog-post/1774800862-a3b4cr/new/init/1774800862-c5d6
Merge Flow
cr/* feature branch
↓ merge
contentrain SSOT branch
↓ merge
main (or default branch)If main has branch protection requiring pull requests, the engine creates a PR and attempts to merge it programmatically.
Branch Guard
The engine ensures the contentrain branch exists before any operation. If it does not exist, it is created from the default branch head.
// Called once per engine instance (cached flag)
await engine.ensureContentBranch()Canonical Serialization
All JSON output follows the canonical format from @contentrain/types:
const CANONICAL_JSON = {
indent: 2, // 2-space indentation
encoding: 'utf-8',
trailingNewline: true, // Final newline character
omitNull: true, // Remove null values
omitDefaults: true, // Remove fields matching their default value
sortKeys: true, // Alphabetical key ordering
}This ensures Studio-written JSON is byte-identical to MCP-written JSON, preventing unnecessary diffs.
import { serializeCanonical } from '~/server/utils/content-serialization'
const json = serializeCanonical(data, fieldDefs)
// Output: sorted keys, 2-space indent, trailing newline, nulls omittedEntry ID Generation
Collection entries use 12-character hex IDs:
import { generateEntryId } from '~/server/utils/content-serialization'
const id = generateEntryId() // e.g., 'a1b2c3d4e5f6'Content Validation
Validation runs against model field definitions before every write:
import { validateContent } from '~/server/utils/content-validation'
const result = validateContent(data, fields, modelId, locale, entryId, {
allEntries: existingEntries, // For unique checks
currentEntryId: entryId,
models: allModels, // For relation resolution
})
if (!result.valid) {
// result.errors contains detailed validation errors
}See Field Types for validation rules per field type.
Path Resolution
File paths are resolved using patterns from @contentrain/types:
import { resolveContentPath, resolveModelPath } from '~/server/utils/content-paths'
const ctx = { contentRoot: '' }
resolveModelPath(ctx, 'blog-post')
// → '.contentrain/models/blog-post.json'
resolveContentPath(ctx, model, 'en')
// → '.contentrain/content/blog/blog-post/en.json'
resolveContentPath(ctx, model, 'en', 'getting-started')
// → '.contentrain/content/blog/blog-post/getting-started/en.md' (document kind)Custom Content Path
Models can override their content path with content_path:
{
"id": "docs",
"kind": "document",
"content_path": "content/docs"
}Path traversal and sensitive directory access are blocked by validation.
Document Support
Document-kind content uses markdown files with YAML frontmatter:
import { parseMarkdownFrontmatter, serializeMarkdownFrontmatter }
from '~/server/utils/content-serialization'
const { frontmatter, body } = parseMarkdownFrontmatter(raw)
const serialized = serializeMarkdownFrontmatter(frontmatter, body)Context Tracking
Every operation updates .contentrain/context.json with:
- Last operation details (tool, model, locale, timestamp)
- Project stats (model count, entry count, active locales)
This context is included in the agent system prompt for conversation continuity.
Bot Author
All commits are authored by the Studio bot:
const BOT_AUTHOR = {
name: 'Contentrain Studio[bot]',
email: '[email protected]',
}Related Pages
- Contentrain Format -- directory structure specification
- Field Types -- validation rules per type
- Agent System -- how the agent invokes the engine
- Branches API -- REST endpoints for branches