Skip to content

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, list

Creating an Engine Instance ​

The engine is created per-request with a GitProvider scoped to the target repository:

ts
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 ​

ts
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:

ts
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 WriteResult

Branch Strategy ​

Constants ​

ts
const CONTENT_BRANCH = 'contentrain'   // SSOT branch
const BRANCH_PREFIX = 'cr/'            // Feature branch prefix

Branch Naming Convention ​

cr/{operation}/{modelId}/{locale}/{timestamp}-{random}

Examples:

  • cr/content/blog-post/en/1774800862-27c1
  • cr/model/blog-post/1774800862-a3b4
  • cr/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.

ts
// Called once per engine instance (cached flag)
await engine.ensureContentBranch()

Canonical Serialization ​

All JSON output follows the canonical format from @contentrain/types:

ts
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.

ts
import { serializeCanonical } from '~/server/utils/content-serialization'

const json = serializeCanonical(data, fieldDefs)
// Output: sorted keys, 2-space indent, trailing newline, nulls omitted

Entry ID Generation ​

Collection entries use 12-character hex IDs:

ts
import { generateEntryId } from '~/server/utils/content-serialization'

const id = generateEntryId()  // e.g., 'a1b2c3d4e5f6'

Content Validation ​

Validation runs against model field definitions before every write:

ts
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:

ts
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:

json
{
  "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:

ts
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:

ts
const BOT_AUTHOR = {
  name: 'Contentrain Studio[bot]',
  email: '[email protected]',
}

Released under the AGPL-3.0 License.