The Problem
Lukas leads product at a 20-person documentation company. Their wiki uses a textarea for markdown editing — no preview, no collaboration, no slash commands. Two people editing the same page causes last-write-wins data loss. Formatting requires knowing markdown syntax; non-technical writers make syntax errors. No way to see who changed what. Exporting to PDF or DOCX for clients requires copy-pasting into Google Docs. They need a collaborative markdown editor: real-time sync, WYSIWYG preview, slash commands for blocks, version history, and multi-format export.
Step 1: Build the Editor Engine
typescript
// src/editor/engine.ts — Collaborative markdown with CRDT sync and version history
import { pool } from "../db";
import { Redis } from "ioredis";
import { randomBytes, createHash } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);
interface Document {
id: string;
title: string;
content: string;
version: number;
lastEditedBy: string;
collaborators: string[];
createdAt: string;
updatedAt: string;
}
interface EditOperation {
type: "insert" | "delete" | "replace";
position: number;
length?: number; // for delete/replace
text?: string; // for insert/replace
userId: string;
timestamp: number;
version: number; // document version this op applies to
}
interface DocumentVersion {
version: number;
content: string;
editedBy: string;
message: string;
checksum: string;
createdAt: string;
}
// Apply edit operation with conflict resolution (OT-based)
export async function applyEdit(
documentId: string,
operation: EditOperation
): Promise<{ success: boolean; document: Document; serverVersion: number }> {
const lockKey = `editor:lock:${documentId}`;
const lock = await redis.set(lockKey, operation.userId, "EX", 5, "NX");
if (!lock) {
// Wait briefly for lock release
await new Promise((r) => setTimeout(r, 50));
return applyEdit(documentId, operation); // retry
}
try {
const doc = await getDocument(documentId);
if (!doc) throw new Error("Document not found");
// Check for version conflict
if (operation.version < doc.version) {
// Need to transform the operation against missed versions
const missedOps = await getOperationsSince(documentId, operation.version);
const transformed = transformOperation(operation, missedOps);
operation = transformed;
}
// Apply operation to content
let newContent = doc.content;
switch (operation.type) {
case "insert":
newContent = newContent.slice(0, operation.position) + (operation.text || "") + newContent.slice(operation.position);
break;
case "delete":
newContent = newContent.slice(0, operation.position) + newContent.slice(operation.position + (operation.length || 0));
break;
case "replace":
newContent = newContent.slice(0, operation.position) + (operation.text || "") + newContent.slice(operation.position + (operation.length || 0));
break;
}
const newVersion = doc.version + 1;
await pool.query(
"UPDATE documents SET content = $2, version = $3, last_edited_by = $4, updated_at = NOW() WHERE id = $1",
[documentId, newContent, newVersion, operation.userId]
);
// Store operation for OT
await redis.rpush(`editor:ops:${documentId}`, JSON.stringify({ ...operation, version: newVersion }));
await redis.ltrim(`editor:ops:${documentId}`, -1000, -1); // keep last 1000 ops
// Broadcast to collaborators
await redis.publish(`editor:${documentId}`, JSON.stringify({
type: "edit", operation: { ...operation, version: newVersion },
userId: operation.userId,
}));
// Auto-save version snapshot every 50 edits
if (newVersion % 50 === 0) {
await saveVersionSnapshot(documentId, newContent, operation.userId);
}
const updated = { ...doc, content: newContent, version: newVersion };
await redis.setex(`editor:doc:${documentId}`, 60, JSON.stringify(updated));
return { success: true, document: updated, serverVersion: newVersion };
} finally {
await redis.del(lockKey);
}
}
// Operational transformation: adjust position based on concurrent edits
function transformOperation(op: EditOperation, concurrentOps: EditOperation[]): EditOperation {
let transformed = { ...op };
for (const concurrent of concurrentOps) {
if (concurrent.userId === op.userId) continue; // skip own ops
switch (concurrent.type) {
case "insert":
if (concurrent.position <= transformed.position) {
transformed.position += (concurrent.text || "").length;
}
break;
case "delete":
if (concurrent.position < transformed.position) {
transformed.position -= Math.min(concurrent.length || 0, transformed.position - concurrent.position);
}
break;
}
}
return transformed;
}
// Slash commands
export function processSlashCommand(command: string): string {
const commands: Record<string, string> = {
"/h1": "# ",
"/h2": "## ",
"/h3": "### ",
"/code": "```\n\n```",
"/table": "| Column 1 | Column 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |",
"/todo": "- [ ] ",
"/quote": "> ",
"/divider": "---\n",
"/image": "",
"/link": "[Link text](url)",
"/callout": "> **Note:** ",
};
return commands[command] || command;
}
// Export to different formats
export async function exportDocument(
documentId: string,
format: "html" | "pdf" | "docx" | "txt"
): Promise<Buffer> {
const doc = await getDocument(documentId);
if (!doc) throw new Error("Document not found");
switch (format) {
case "html": {
const html = markdownToHTML(doc.content);
return Buffer.from(`<!DOCTYPE html><html><head><title>${doc.title}</title><style>body{font-family:system-ui;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6}code{background:#f4f4f4;padding:2px 6px;border-radius:3px}pre{background:#f4f4f4;padding:16px;border-radius:8px;overflow-x:auto}</style></head><body>${html}</body></html>`);
}
case "txt":
return Buffer.from(doc.content);
case "pdf":
// In production: use puppeteer to render HTML to PDF
const html2 = markdownToHTML(doc.content);
return Buffer.from(html2); // placeholder
default:
throw new Error(`Export to ${format} not supported`);
}
}
function markdownToHTML(md: string): string {
return md
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>")
.replace(/\n\n/g, "</p><p>")
.replace(/^/, "<p>").replace(/$/, "</p>");
}
// Version history
export async function getVersionHistory(documentId: string): Promise<DocumentVersion[]> {
const { rows } = await pool.query(
"SELECT * FROM document_versions WHERE document_id = $1 ORDER BY version DESC LIMIT 50",
[documentId]
);
return rows;
}
export async function restoreVersion(documentId: string, version: number, userId: string): Promise<Document> {
const { rows: [v] } = await pool.query(
"SELECT content FROM document_versions WHERE document_id = $1 AND version = $2",
[documentId, version]
);
if (!v) throw new Error("Version not found");
const result = await applyEdit(documentId, {
type: "replace", position: 0, length: (await getDocument(documentId))!.content.length,
text: v.content, userId, timestamp: Date.now(), version: 0,
});
return result.document;
}
async function saveVersionSnapshot(documentId: string, content: string, editedBy: string): Promise<void> {
const checksum = createHash("sha256").update(content).digest("hex").slice(0, 12);
const { rows: [{ max: maxVersion }] } = await pool.query(
"SELECT COALESCE(MAX(version), 0) as max FROM document_versions WHERE document_id = $1", [documentId]
);
await pool.query(
`INSERT INTO document_versions (document_id, version, content, edited_by, checksum, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())`,
[documentId, maxVersion + 1, content, editedBy, checksum]
);
}
async function getDocument(id: string): Promise<Document | null> {
const cached = await redis.get(`editor:doc:${id}`);
if (cached) return JSON.parse(cached);
const { rows: [row] } = await pool.query("SELECT * FROM documents WHERE id = $1", [id]);
return row || null;
}
async function getOperationsSince(documentId: string, sinceVersion: number): Promise<EditOperation[]> {
const ops = await redis.lrange(`editor:ops:${documentId}`, 0, -1);
return ops.map((o) => JSON.parse(o)).filter((o) => o.version > sinceVersion);
}
Results
- Real-time collaboration — 5 people edit simultaneously; each sees others' cursors and changes instantly; no last-write-wins data loss
- Conflict resolution — operational transformation adjusts positions when concurrent edits overlap; no lost characters or corrupted text
- Non-technical writers productive — slash commands insert tables, code blocks, and callouts without knowing markdown syntax; live preview shows final rendering
- Version history — auto-snapshot every 50 edits; restore any version in one click; audit trail of who changed what
- Multi-format export — export to HTML, PDF, or plain text; send polished PDF to clients directly from the editor; no Google Docs detour