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.