MDX.org.ai
Structured DatamdxldFormat Conversion

@mdxld/diff

Git-style diffing, patching, and 3-way merge for text and structured data

@mdxld/diff

Git-style text diffing, unified patches, 3-way merge, and structured object comparison. Essential for version control in MDX editors and collaborative editing.

Installation

pnpm add @mdxld/diff

Quick Start

import {
  // Text diffing
  diffLines, diffWords, diffChars,
  // Git-style patches
  createPatch, applyPatch, parsePatch,
  // 3-way merge
  merge3way, resolveConflict,
  // Object diffing
  diffObjects, diffJSON, diffArrays,
  // Utilities
  formatChanges, countChanges
} from '@mdxld/diff'
 
// Diff two documents
const changes = diffLines(oldContent, newContent)
 
// Create a patch
const patch = createPatch('document.md', oldContent, newContent)
 
// Apply a patch
const updated = applyPatch(oldContent, patch)

Text Diffing

diffLines(oldStr, newStr)

Diff two strings line by line. Most common for documents.

const changes = diffLines(oldContent, newContent)
 
for (const change of changes) {
  if (change.added) {
    console.log('+', change.value)
  } else if (change.removed) {
    console.log('-', change.value)
  }
}

diffWords(oldStr, newStr)

Diff word by word. Useful for inline changes.

const changes = diffWords('hello world', 'hello there')
// [
//   { value: 'hello ' },
//   { value: 'world', removed: true },
//   { value: 'there', added: true }
// ]

diffChars(oldStr, newStr)

Character-level diff. Fine-grained comparison.

const changes = diffChars('hello', 'hallo')
// [
//   { value: 'h' },
//   { value: 'e', removed: true },
//   { value: 'a', added: true },
//   { value: 'llo' }
// ]

Options

interface DiffOptions {
  ignoreWhitespace?: boolean  // Ignore leading/trailing whitespace
  ignoreCase?: boolean        // Case-insensitive comparison
  newlineIsToken?: boolean    // Treat newlines as separate tokens
}
 
const changes = diffLines(old, new, { ignoreWhitespace: true })

Git-Style Patches

createPatch(fileName, oldStr, newStr)

Create a unified diff patch (like git diff).

const patch = createPatch('document.md', oldContent, newContent)

Output:

--- document.md
+++ document.md
@@ -1,4 +1,4 @@
 # Hello World
 
-This is the old content.
+This is the new content.
 
 More text here.

Patch Options

interface PatchOptions {
  context?: number       // Context lines (default: 3)
  oldFileName?: string   // Original filename
  newFileName?: string   // New filename
  oldHeader?: string     // Header for old file
  newHeader?: string     // Header for new file
}
 
const patch = createPatch('doc.md', old, new, {
  context: 5,
  oldHeader: 'revision 1',
  newHeader: 'revision 2',
})

applyPatch(source, patch)

Apply a patch to source content.

const newContent = applyPatch(oldContent, patch)
 
if (newContent === false) {
  console.error('Patch failed to apply cleanly')
}

parsePatch(patchString)

Parse a patch string into structured data.

const patches = parsePatch(patchString)
 
for (const patch of patches) {
  console.log(`${patch.oldFileName} → ${patch.newFileName}`)
  console.log(`${patch.hunks.length} hunks`)
 
  for (const hunk of patch.hunks) {
    console.log(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`)
  }
}

structuredPatch(oldFile, newFile, oldStr, newStr)

Get structured patch data for programmatic access.

const patch = structuredPatch('old.md', 'new.md', oldContent, newContent)
 
for (const hunk of patch.hunks) {
  for (const line of hunk.lines) {
    console.log(line)  // Lines prefixed with +, -, or space
  }
}

reversePatch(patch)

Create a patch that undoes the original.

const reversed = reversePatch(patches)
const restored = applyPatch(newContent, reversed)

3-Way Merge

merge3way(base, ours, theirs)

Perform a 3-way merge like git merge.

const base = `line 1
line 2
line 3`
 
const ours = `line 1
line 2 modified by us
line 3`
 
const theirs = `line 1
line 2
line 3 modified by them`
 
const result = merge3way(base, ours, theirs)
 
if (result.hasConflicts) {
  console.log('Conflicts found:', result.conflicts.length)
  console.log(result.merged)  // Contains conflict markers
} else {
  console.log('Clean merge:', result.merged)
}

Conflict Markers

When conflicts occur, the merged content contains markers:

<<<<<<< ours
line 2 modified by us
=======
line 2 modified by them
>>>>>>> theirs

resolveConflict(content, resolution)

Resolve all conflicts by choosing a side.

// Choose our changes
const resolved = resolveConflict(mergedWithConflicts, 'ours')
 
// Choose their changes
const resolved = resolveConflict(mergedWithConflicts, 'theirs')
 
// Use the base version
const resolved = resolveConflict(mergedWithConflicts, 'base')

Object Diffing

diffObjects(oldObj, newObj)

Get structured changes between objects.

const diff = diffObjects(
  { name: 'John', age: 30, city: 'LA' },
  { name: 'John', age: 31, country: 'USA' }
)
 
// {
//   added: { country: 'USA' },
//   removed: { city: 'LA' },
//   modified: { age: { from: 30, to: 31 } },
//   unchanged: { name: 'John' },
//   hasChanges: true
// }

diffJSON(oldObj, newObj)

Create a unified diff of JSON representations.

const patch = diffJSON(oldConfig, newConfig, {
  oldFileName: 'config.old.json',
  newFileName: 'config.new.json',
})

diffArrays(oldArr, newArr)

Track additions and removals in arrays.

const diff = diffArrays(['a', 'b', 'c'], ['a', 'c', 'd'])
// {
//   added: [{ index: 2, value: 'd' }],
//   removed: [{ index: 1, value: 'b' }],
//   moved: [],
//   hasChanges: true
// }

Utilities

formatChanges(changes)

Convert changes to a human-readable string.

const changes = diffLines(old, new)
console.log(formatChanges(changes))
// -old line
// +new line

countChanges(changes)

Count additions and deletions.

const { additions, deletions } = countChanges(changes)
console.log(`+${additions} -${deletions}`)

canApplyPatch(source, patch)

Check if a patch can be applied cleanly.

if (canApplyPatch(content, patch)) {
  const result = applyPatch(content, patch)
}

getPatchStats(patch)

Get statistics from a patch.

const stats = getPatchStats(patch)
// { files: 2, additions: 10, deletions: 5, hunks: 3 }

Use Cases

Version History for MDX Editor

import { createPatch, applyPatch } from '@mdxld/diff'
 
class DocumentHistory {
  private patches: string[] = []
  private baseContent: string
 
  constructor(initialContent: string) {
    this.baseContent = initialContent
  }
 
  save(newContent: string, currentContent: string) {
    const patch = createPatch('document.mdx', currentContent, newContent)
    this.patches.push(patch)
  }
 
  getVersion(index: number): string {
    let content = this.baseContent
    for (let i = 0; i <= index && i < this.patches.length; i++) {
      const result = applyPatch(content, this.patches[i])
      if (result !== false) content = result
    }
    return content
  }
 
  getDiff(fromIndex: number, toIndex: number): string {
    const from = this.getVersion(fromIndex)
    const to = this.getVersion(toIndex)
    return createPatch('document.mdx', from, to)
  }
}

Collaborative Editing

import { merge3way, resolveConflict } from '@mdxld/diff'
 
async function mergeEdits(base: string, userA: string, userB: string) {
  const result = merge3way(base, userA, userB)
 
  if (result.hasConflicts) {
    // Show conflicts to user for manual resolution
    // Or auto-resolve with a strategy
    return resolveConflict(result.merged, 'theirs')
  }
 
  return result.merged
}

Config File Comparison

import { diffObjects, diffJSON } from '@mdxld/diff'
 
function compareConfigs(oldConfig: object, newConfig: object) {
  // Structured diff for programmatic use
  const structured = diffObjects(oldConfig, newConfig)
 
  if (structured.hasChanges) {
    console.log('Added keys:', Object.keys(structured.added))
    console.log('Removed keys:', Object.keys(structured.removed))
    console.log('Modified keys:', Object.keys(structured.modified))
  }
 
  // Unified diff for display
  const unified = diffJSON(oldConfig, newConfig)
  console.log(unified)
}

Database Sync

import { diffPaths, applyPaths, merge3wayObjects } from '@mdxld/diff'
import { toMarkdown, fromMarkdown } from '@mdxld/markdown'
 
// Load from database
const doc = await db.Document.get(id)
 
// Render for editing
const markdown = toMarkdown(doc)
 
// User edits...
const edited = await editor.edit(markdown)
 
// Extract and compare, leaf path by leaf path
const extracted = fromMarkdown(edited)
const changes = diffPaths(doc, extracted)
 
if (changes.hasChanges) {
  // Show what changed
  console.log('Changes:', changes.modified)
 
  // Save if approved: overlay the extracted paths onto the record
  await db.Document.update(id, applyPaths(doc, extracted))
}
 
// When the record ALSO changed since it was rendered, merge 3-way instead:
const current = await db.Document.get(id)
const { merged, conflicts } = merge3wayObjects(doc, current, applyPaths(doc, extracted))

@mdxld/extract re-exports these as diff, applyExtract and mergeExtract for the template-based round trip; @mdxld/markdown exports no diff of its own.

PackageDescription
@mdxld/markdownMarkdown conversion with diff support
@mdxld/extractTemplate-based extraction
mdxdbDocument storage