Monaco Diff Viewer - VitePress Implementation Reference
Audience: LLMs implementing Monaco diff views in VitePress blog sites Based on: POC-1 (Build & Render), POC-2.1 (Props), POC-2.2 (Theme), POC-3 (File Loading), POC-4 (Comprehensive) Production Component:
MonacoDiff.vuecombines all validated patterns
Overview
This reference documents the production-ready MonacoDiff.vue component that combines:
- File loading from
docs/assets/via VitePress content loader - Theme synchronization with VitePress light/dark mode
- Props-based and file-based content loading
- Comprehensive error handling and validation
Component: docs/.vitepress/components/MonacoDiff.vue
1. Architecture Patterns
Component Lifecycle Pattern (SSR-Safe)
Critical: Both Monaco Editor AND workers must be dynamically imported to avoid SSR failures.
// 1. Use type-only import for TypeScript types (does not execute at runtime)
import type * as Monaco from 'monaco-editor'
// 2. Create nullable monaco reference for use in lifecycle hooks
let monaco: typeof Monaco | null = null
// 3. Dynamically import Monaco + workers in onMounted (browser-only)
onMounted(async () => {
if (!container.value) return
// Import Monaco Editor and all workers dynamically
const [
monacoModule, // Monaco Editor library itself
{ default: editorWorker },
{ default: jsonWorker },
{ default: cssWorker },
{ default: htmlWorker },
{ default: tsWorker }
] = await Promise.all([
import('monaco-editor'), // ⚠️ CRITICAL: Monaco itself must be dynamic
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
import('monaco-editor/esm/vs/language/json/json.worker?worker'),
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
import('monaco-editor/esm/vs/language/html/html.worker?worker'),
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker')
])
// Store monaco reference for use in watch blocks
monaco = monacoModule
// 4. Configure workers after dynamic import
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === 'json') return new jsonWorker()
if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker()
if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker()
if (label === 'typescript' || label === 'javascript') return new tsWorker()
return new editorWorker()
}
}
// 5. Create diff editor using dynamically imported monaco
diffEditor = monaco.editor.createDiffEditor(container.value, {
readOnly: true,
originalEditable: false,
renderSideBySide: true,
enableSplitViewResizing: true,
renderOverviewRuler: true,
automaticLayout: true,
contextmenu: false,
theme: monacoTheme.value,
wordWrap: 'on'
})
// 6. Set initial models
const originalModel = monaco.editor.createModel(oldContent, language)
const modifiedModel = monaco.editor.createModel(newContent, language)
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
})
// 7. Use watch() for reactive prop updates (with null check)
watch(() => props.oldContent, (newValue) => {
if (!diffEditor || !monaco) return // ⚠️ Check monaco availability
const model = diffEditor.getOriginalEditor().getModel()
if (model && model.getValue() !== newValue) {
model.setValue(newValue) // Reuse model - no createModel
}
})
// 8. Watch language changes (with null check)
watch(() => props.language, (newLang) => {
if (!diffEditor || !monaco) return // ⚠️ Check monaco availability
const originalModel = diffEditor.getOriginalEditor().getModel()
const modifiedModel = diffEditor.getModifiedEditor().getModel()
if (originalModel) monaco.editor.setModelLanguage(originalModel, newLang)
if (modifiedModel) monaco.editor.setModelLanguage(modifiedModel, newLang)
})
// 9. Cleanup in onBeforeUnmount
onBeforeUnmount(() => {
diffEditor?.dispose()
})SSR-Safe Loading Pattern (Critical)
<!-- In markdown page -->
<script setup>
import { defineAsyncComponent } from 'vue'
import { inBrowser } from 'vitepress'
const MonacoDiff = inBrowser
? defineAsyncComponent(() => import('./.vitepress/components/MonacoDiffFile.vue'))
: () => null
</script>
<ClientOnly>
<MonacoDiff
oldFile="assets/old.md"
newFile="assets/new.md"
language="markdown"
/>
</ClientOnly>Page Layout Requirements
---
layout: doc
aside: false # REQUIRED: Removes right sidebar for wide diff view
---Viewport Requirements: Side-by-side rendering requires >= 1440px width. Below this, Monaco falls back to inline diff.
2. Critical Configurations
VitePress Config (docs/.vitepress/config.mts)
export default defineConfig({
srcExclude: ['**/assets/**'], // Exclude from page generation
vite: {
ssr: {
noExternal: ['monaco-editor'] // REQUIRED: Bundle Monaco for SSR
}
// NO vite-plugin-monaco-editor - use native Vite workers
}
})Monaco Editor Options
monaco.editor.createDiffEditor(container, {
readOnly: true, // Prevent editing
originalEditable: false, // Lock left pane
renderSideBySide: true, // Side-by-side (not unified)
enableSplitViewResizing: true, // User can resize panes
renderOverviewRuler: true, // Show minimap
automaticLayout: true, // Auto-resize on container changes
contextmenu: false, // Disables right-click context menu
theme: monacoTheme.value, // Dynamic theme from VitePress (vs-dark / vs)
wordWrap: 'on' // Enable word wrapping for long lines
})3. Component API
Props Interface
interface Props {
// Props-based content (POC-2.1)
oldContent?: string
newContent?: string
// File-based content (POC-3)
oldFile?: string // e.g., 'default-system-prompt.md'
newFile?: string // e.g., 'output-style-system-prompt.md'
language?: string // Default: 'markdown'
}
const props = withDefaults(defineProps<Props>(), {
language: 'markdown'
})Usage Examples
<!-- Props-based content -->
<MonacoDiff
:oldContent="'const x = 1;'"
:newContent="'const x = 10;'"
language="javascript"
/>
<!-- File-based content -->
<MonacoDiff
oldFile="default-system-prompt.md"
newFile="output-style-system-prompt.md"
language="markdown"
/>Reactive Updates Pattern (POC-2.1 Validated)
// Use watch() + model.setValue() (NOT createModel)
watch(() => props.oldContent, (newValue) => {
const model = diffEditor.getOriginalEditor().getModel()
if (model && model.getValue() !== newValue) {
model.setValue(newValue) // Efficient - reuses model
}
})
watch(() => props.language, (newLang) => {
const originalModel = diffEditor.getOriginalEditor().getModel()
const modifiedModel = diffEditor.getModifiedEditor().getModel()
if (originalModel) monaco.editor.setModelLanguage(originalModel, newLang)
if (modifiedModel) monaco.editor.setModelLanguage(modifiedModel, newLang)
})4. Data Loading Pattern (POC-3, POC-5)
Problem: srcExclude Blocks import.meta.glob
VitePress srcExclude prevents files from being processed by Vite, breaking import.meta.glob imports.
Solution: Node.js fs-based Data Loader
⚠️ Critical: VitePress createContentLoader only supports Markdown files. For non-markdown files (TypeScript, JavaScript, Vue, etc.), you MUST use Node.js fs module.
File: docs/.vitepress/loaders/assets.data.ts
import fs from 'node:fs'
import path from 'node:path'
export interface AssetFile {
path: string
content: string
}
declare const data: AssetFile[]
export { data }
// VitePress createContentLoader only supports Markdown files
// For non-markdown files (TypeScript, JavaScript, Vue, etc.), use Node.js fs
// This approach is validated by POC-5 (commit ebfc237)
export default {
load() {
const assetsDir = path.resolve(__dirname, '../../assets')
const files: AssetFile[] = []
// Supported extensions for multi-language syntax highlighting
const supportedExtensions = ['.md', '.ts', '.js', '.vue', '.html', '.css', '.json', '.yaml']
// Read all files from assets directory
const dirEntries = fs.readdirSync(assetsDir, { withFileTypes: true })
for (const entry of dirEntries) {
if (entry.isFile()) {
const ext = path.extname(entry.name)
// Only include files with supported extensions
if (supportedExtensions.includes(ext)) {
const filePath = path.join(assetsDir, entry.name)
const content = fs.readFileSync(filePath, 'utf-8')
files.push({
path: entry.name,
content
})
}
}
}
return files
}
}Component Usage:
import { data as assetFiles } from '../loaders/assets.data'
// Convert to Record<filename, content>
const fileContents: Record<string, string> = {}
for (const file of assetFiles) {
fileContents[file.path] = file.content
}
console.log('[MonacoDiffFile] Loaded files:', Object.keys(fileContents))Path Normalization
// Strip directory prefixes - data loader provides just filenames
function normalizeFilePath(path: string): string {
return path.split('/').pop() || path
}
function loadFileContent(path: string): string | null {
const normalizedPath = normalizeFilePath(path)
const content = fileContents[normalizedPath]
return content ?? null
}Error Handling Pattern
const contentOrError = computed(() => {
let oldContentValue = ''
let newContentValue = ''
let errorMessage: string | null = null
// Validate prop combinations
if (props.oldContent && props.oldFile) {
errorMessage = 'Cannot specify both oldContent and oldFile'
} else if (!props.oldContent && !props.oldFile) {
errorMessage = 'Must specify either oldContent or oldFile'
} else if (props.oldFile) {
const content = loadFileContent(props.oldFile)
if (content === null) {
errorMessage = `File not found: ${props.oldFile}`
} else {
oldContentValue = content
}
}
// Similar validation for newContent/newFile...
return { oldContent: oldContentValue, newContent: newContentValue, error: errorMessage }
})5. Common Pitfalls
Pitfall 1: createContentLoader Only Supports Markdown
// ❌ FAILS - createContentLoader only loads .md files
import { createContentLoader } from 'vitepress'
export default createContentLoader('assets/*.{md,ts,js}', { includeSrc: true })
// Result: Only .md files load, .ts and .js are ignored
// ❌ ALSO FAILS - files in srcExclude not processed by Vite
const files = import.meta.glob('/docs/assets/*.md', { eager: true, query: '?raw' })
// Result: empty object {}
// ✅ WORKS - Use Node.js fs for non-markdown files
import fs from 'node:fs'
import path from 'node:path'
export default {
load() {
const assetsDir = path.resolve(__dirname, '../../assets')
const files = []
const dirEntries = fs.readdirSync(assetsDir, { withFileTypes: true })
for (const entry of dirEntries) {
if (entry.isFile()) {
const content = fs.readFileSync(path.join(assetsDir, entry.name), 'utf-8')
files.push({ path: entry.name, content })
}
}
return files
}
}Critical Discovery (POC-6): VitePress createContentLoader is markdown-only by design. Attempting to load TypeScript, JavaScript, Vue, or other file types will silently fail. Always use the fs-based approach shown above for multi-language support.
Pitfall 2: SSR Errors with Monaco
Two levels of SSR protection required:
- Component level - Use
defineAsyncComponent+<ClientOnly> - Worker level - Use dynamic imports in
onMounted()
<!-- ❌ FAILS - Monaco imported during SSR -->
<script setup>
import MonacoDiff from './components/MonacoDiff.vue'
</script>
<!-- ✅ WORKS - Client-only loading -->
<script setup>
import { defineAsyncComponent } from 'vue'
import { inBrowser } from 'vitepress'
const MonacoDiff = inBrowser
? defineAsyncComponent(() => import('./components/MonacoDiff.vue'))
: () => null
</script>
<ClientOnly><MonacoDiff /></ClientOnly>Inside component - Complete SSR-safe pattern:
// ❌ FAILS - Top-level imports execute during SSR build
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
// ✅ WORKS - Type-only import + dynamic runtime imports
import type * as Monaco from 'monaco-editor'
let monaco: typeof Monaco | null = null
onMounted(async () => {
// Dynamically import Monaco + workers (browser-only execution)
const [
monacoModule,
{ default: editorWorker }
// ... other workers
] = await Promise.all([
import('monaco-editor'), // Monaco library itself
import('monaco-editor/esm/vs/editor/editor.worker?worker')
// ... other worker imports
])
monaco = monacoModule // Store for use in watch blocks
// Configure and use monaco...
})
// Watch blocks must check monaco availability
watch(someProp, () => {
if (!monaco) return // Guard against SSR or pre-mount execution
monaco.editor.doSomething()
})Pitfall 3: Monaco DOM Structure Assumptions
Diff editor creates 3 .monaco-editor elements:
- Left pane (original)
- Right pane (modified)
- Overview ruler (minimap)
Pitfall 4: Worker Plugin Conflicts
// ❌ DON'T USE - conflicts with VitePress
import monacoEditorPlugin from 'vite-plugin-monaco-editor'
// ✅ USE - Vite native workers (2025 best practice)
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'Pitfall 5: Memory Leaks with createModel
// ❌ MEMORY LEAK - creates new model without disposing old one
watch(content, (newValue) => {
const model = monaco.editor.createModel(newValue, 'javascript')
editor.setModel(model)
})
// ✅ CORRECT - reuse existing model
watch(content, (newValue) => {
const model = editor.getModel()
model.setValue(newValue)
})6. Theme Synchronization (POC-2.2)
VitePress Theme Detection
import { useData } from 'vitepress'
import { computed, watch } from 'vue'
const { isDark } = useData() // Reactive VitePress theme state
// Map to Monaco theme names
const monacoTheme = computed(() => isDark.value ? 'vs-dark' : 'vs')
// Set initial theme
monaco.editor.createDiffEditor(container.value, {
theme: monacoTheme.value
})
// Reactive theme synchronization
watch(monacoTheme, (newTheme) => {
if (!diffEditor) return
monaco.editor.setTheme(newTheme) // Updates all editors globally
})Performance: Theme switches complete in <100ms, stable across rapid toggles.
7. Worker Setup (2025 Best Practice - SSR-Safe)
Complete Dynamic Import Pattern
⚠️ Critical: BOTH Monaco Editor and workers must be dynamically imported to avoid SSR errors.
// Step 1: Type-only import (no runtime execution)
import type * as Monaco from 'monaco-editor'
// Step 2: Create nullable reference
let monaco: typeof Monaco | null = null
// Step 3: Dynamic imports in onMounted
onMounted(async () => {
// Import Monaco Editor library + all language workers
const [
monacoModule, // ⚠️ Monaco itself must be dynamic!
{ default: editorWorker },
{ default: jsonWorker },
{ default: cssWorker },
{ default: htmlWorker },
{ default: tsWorker }
] = await Promise.all([
import('monaco-editor'), // Main library
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
import('monaco-editor/esm/vs/language/json/json.worker?worker'),
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
import('monaco-editor/esm/vs/language/html/html.worker?worker'),
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker')
])
// Store monaco reference for watch blocks
monaco = monacoModule
// Configure worker resolution
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === 'json') return new jsonWorker()
if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker()
if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker()
if (label === 'typescript' || label === 'javascript') return new tsWorker()
return new editorWorker()
}
}
// Now safe to use monaco.editor.* APIs
diffEditor = monaco.editor.createDiffEditor(container.value, { /* config */ })
})Build Output
Workers successfully bundle to production:
dist/assets/
css.worker-[hash].js (1.0 MB)
editor.worker-[hash].js (246 KB)
html.worker-[hash].js (677 KB)
json.worker-[hash].js (374 KB)
ts.worker-[hash].js (5.8 MB)Total: ~8.1 MB (expected for full Monaco with TypeScript support)
Why Dynamic Imports Are Required
Root Cause: Monaco Editor's module-level code and worker imports reference browser globals (window, self, document) that don't exist in Node.js SSR environments.
What Fails:
import * as monaco from 'monaco-editor'- Module initialization code runs during SSRimport worker from 'monaco-editor/...?worker'- Worker imports trigger browser-specific code- Both cause "window is not defined" or "ReferenceError: self is not defined" during build
Solution Pattern:
// ✅ Use type-only import (erased at runtime, safe for SSR)
import type * as Monaco from 'monaco-editor'
// ✅ Dynamic imports in onMounted (browser-only execution)
onMounted(async () => {
const [monacoModule, ...workers] = await Promise.all([
import('monaco-editor'),
// worker imports...
])
monaco = monacoModule
})Why This Works:
- Deferred execution - Code only runs after component mounts in browser
- No SSR evaluation - Build process doesn't execute dynamic imports
- Maintains bundling - Vite still bundles Monaco and workers for production
- Type-safe -
import typeprovides TypeScript types without runtime import - Null-safe - Watch blocks check
if (!monaco)before using APIs
Why This Pattern Works
- No plugin dependency - Vite handles
?workerimports natively - VitePress compatible - No conflicts with VitePress build process
- SSR-safe - Dynamic imports + worker setup in
onMounted(client-only) - Production-ready - Workers automatically bundled and hashed
References:
- VitePress Issue #2832
- GitHub Issue #10 - SSR Build Failure Fix
Quick Start Checklist
1. Installation & Configuration
- Install:
npm install monaco-editor@0.54.0 - VitePress Config: Add
ssr.noExternal: ['monaco-editor']tovite.ssrin config.mts - Exclude assets: Add
srcExclude: ['**/assets/**']to prevent asset files from being rendered as pages
2. Component Implementation (SSR-Safe Pattern)
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import type * as Monaco from 'monaco-editor' // ⚠️ Type-only import
let monaco: typeof Monaco | null = null
let diffEditor = null
const container = ref(null)
onMounted(async () => {
if (!container.value) return
// ⚠️ CRITICAL: Dynamic imports for Monaco + workers
const [monacoModule, { default: editorWorker }, ...otherWorkers] = await Promise.all([
import('monaco-editor'), // Monaco itself must be dynamic
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
// ... other worker imports
])
monaco = monacoModule // Store for watch blocks
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
// Worker resolution logic
return new editorWorker()
}
}
diffEditor = monaco.editor.createDiffEditor(container.value, {
readOnly: true,
renderSideBySide: true,
automaticLayout: true,
theme: 'vs-dark'
})
// Set models...
})
onBeforeUnmount(() => {
diffEditor?.dispose()
})
</script>
<template>
<div ref="container" style="height: 600px; border: 1px solid #ccc;" />
</template>3. Page Usage (SSR-Safe Loading)
<!-- In your markdown page -->
<script setup>
import { defineAsyncComponent } from 'vue'
import { inBrowser } from 'vitepress'
// ⚠️ CRITICAL: Wrap component in SSR guard
const MonacoDiff = inBrowser
? defineAsyncComponent(() => import('./.vitepress/components/MonacoDiff.vue'))
: () => null
</script>
<ClientOnly>
<MonacoDiff oldFile="before.md" newFile="after.md" language="markdown" />
</ClientOnly>4. Page Configuration
---
layout: doc
aside: false # Required for wide diff view
---5. Data Loader (for file-based content)
- Create:
docs/.vitepress/loaders/assets.data.ts - Use Node.js fs: VitePress
createContentLoaderonly supports.mdfiles - Export interface: Return array of
{ path: string, content: string }
6. Validation Checklist
- ✓ Build succeeds:
npm run docs:buildcompletes without SSR errors - ✓ No "window is not defined" errors
- ✓ Monaco renders correctly in browser
- ✓ Theme sync works (if implemented)
- ✓ Props update reactively (if using dynamic content)
- ✓ Viewport >= 1440px for side-by-side view
Bundle Size & Performance
- Bundle size: ~8 MB for full Monaco (optimize by excluding unused language workers)
- Browser support: Chrome, Firefox, Safari, Edge (modern versions with Web Worker support)
- Performance: Theme switches <100ms, no lag on rapid toggles, efficient prop updates
Troubleshooting SSR Issues
Error: "window is not defined"
Symptom: Build fails with ReferenceError: window is not defined
Diagnosis:
npm run docs:build
# Error output shows file path where window is referencedCommon Causes & Fixes:
Top-level Monaco import
typescript// ❌ Problem import * as monaco from 'monaco-editor' // ✅ Solution import type * as Monaco from 'monaco-editor' let monaco: typeof Monaco | null = null onMounted(async () => { monaco = await import('monaco-editor') })Top-level worker imports
typescript// ❌ Problem import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' // ✅ Solution onMounted(async () => { const { default: editorWorker } = await import('monaco-editor/esm/vs/editor/editor.worker?worker') })Component not wrapped in SSR guard
vue<!-- ❌ Problem --> <script setup> import MonacoDiff from './.vitepress/components/MonacoDiff.vue' </script> <MonacoDiff /> <!-- ✅ Solution --> <script setup> import { defineAsyncComponent } from 'vue' import { inBrowser } from 'vitepress' const MonacoDiff = inBrowser ? defineAsyncComponent(() => import('./.vitepress/components/MonacoDiff.vue')) : () => null </script> <ClientOnly><MonacoDiff /></ClientOnly>Missing monaco null check in watch blocks
typescript// ❌ Problem watch(props.language, (newLang) => { monaco.editor.setModelLanguage(model, newLang) // Crashes if monaco is null }) // ✅ Solution watch(props.language, (newLang) => { if (!monaco) return // Guard against pre-mount execution monaco.editor.setModelLanguage(model, newLang) })
Verification After Fix
# Clean build artifacts
rm -rf docs/.vitepress/dist docs/.vitepress/cache
# Rebuild (should complete without errors)
npm run docs:build
# Expected output:
# ✓ building client + server bundles...
# ✓ rendering pages...
# Preview to verify Monaco works in browser
npm run docs:previewStill Having Issues?
- Check VitePress config includes:
vite.ssr.noExternal: ['monaco-editor'] - Verify all
monaco.*API calls are insideonMounted()or guarded withif (!monaco) - Ensure TypeScript understands
import type(requires TypeScript 3.8+) - Review error stack trace for exact line causing SSR failure