CodeMirror Extensions
Extend the code editor with custom CodeMirror 6 extensions -- add syntax highlighting, keymaps, decorations, linting, and more.
Overview
SrcNexus Code Editor uses CodeMirror 6 as its underlying code editing engine. The extension system allows you to register custom CM6 extensions that are automatically injected into editor tabs based on file type. This lets extensions modify the editor's behavior, appearance, and capabilities without touching the core editor code.
There are two registration modes:
| Mode | Where code lives | Best for |
|---|---|---|
| Inline | jsCode string in manifest or SDK call | Small extensions (themes, simple decorations) |
| File-based | Bundled .js file in the extension's directory | Complex extensions (keymaps, view plugins, state fields) |
Both modes can be used declaratively in manifest.json or programmatically at runtime via the SDK.
Shared CodeMirror packages
The editor exposes CodeMirror packages on window.CM so that extension code shares the same CM6 instances as the host editor. The following packages are available:
@codemirror/view@codemirror/state@codemirror/commands@codemirror/language@codemirror/search@codemirror/lint@lezer/highlight@codemirror/merge(available in diff editor only)
Access them from your extension code with:
const { EditorView } = window.CM['@codemirror/view'];
const { StateField, StateEffect } = window.CM['@codemirror/state'];
Inline Mode
Inline mode embeds the JavaScript code directly as a string. The code is evaluated as a function body -- you must use return to provide the CM6 Extension or Extension array.
Manifest declaration
Declare inline extensions in manifest.json under contributes.codemirrorExtensions:
{
"contributes": {
"codemirrorExtensions": [
{
"id": "my-ext.gutterStyle",
"jsCode": "const { EditorView } = window.CM['@codemirror/view']; return EditorView.theme({ '.cm-gutters': { backgroundColor: '#1e1e2e' }, '.cm-activeLineGutter': { backgroundColor: '#313244' } });",
"description": "Custom gutter styling",
"fileExtensions": [".js", ".ts"]
}
]
}
}
Runtime registration
Register inline extensions at runtime using the SDK:
import editor from "@srcnexus/ext-sdk";
await editor.extensions.registerCodeMirrorExtension(
"my-ext.lineNumbers",
`
const { EditorView } = window.CM['@codemirror/view'];
return EditorView.theme({
'.cm-gutters': { backgroundColor: '#1e1e2e' },
'.cm-activeLineGutter': { backgroundColor: '#313244' },
});
`,
{
fileExtensions: [".js", ".ts"],
description: "Custom gutter styling",
}
);
The SDK function signature for inline mode is:
extensions.registerCodeMirrorExtension(
id: string,
jsCode: string,
options?: {
fileExtensions?: string[];
description?: string;
}
): Promise<void>
Inline mode notes
- The
jsCodestring is wrapped in a function and evaluated -- always usereturnto provide the extension. - The code runs in the editor WebView context, so
window.CMandwindow.editorare available. - The 3 MB size limit applies to inline code as well.
File-based Mode
File-based mode is designed for larger, more complex extensions. You bundle your CM6 extension as a standalone JavaScript file in the extension's dist/ directory. The editor reads the file at activation time, injects it into the WebView, and calls your exported factory function.
Step 1: Write the extension
Create a TypeScript (or JavaScript) source file that exports a factory function. The function receives optional params and must return a CM6 Extension or Extension array.
// src/cm-my-plugin.ts
import { EditorView, ViewPlugin, Decoration } from "@codemirror/view";
import { StateField, StateEffect } from "@codemirror/state";
export default function createExtension(params: any) {
const theme = params?.theme ?? "dark";
return [
EditorView.theme({
".cm-content": {
fontFamily: theme === "dark" ? "Fira Code" : "monospace",
},
}),
// ... additional CM6 extensions
];
}
Step 2: Configure the build
Use esbuild (or a similar bundler) to produce an IIFE bundle with globalName set to __cmFactory. You must also use the cm-external plugin pattern so that CodeMirror imports resolve to the shared window.CM packages rather than bundling duplicate copies.
const esbuild = require("esbuild");
await esbuild.build({
entryPoints: ["src/cm-my-plugin.ts"],
bundle: true,
outfile: "dist/cm-my-plugin.js",
format: "iife",
globalName: "__cmFactory", // Required -- the editor looks for this global
platform: "neutral",
target: "es2020",
plugins: [
{
name: "cm-external",
setup(build) {
const cmPackages = [
"@codemirror/view",
"@codemirror/state",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/search",
"@codemirror/lint",
"@lezer/highlight",
];
const filter = new RegExp(
`^(${cmPackages.map((p) => p.replace("/", "\\/")).join("|")})$`
);
build.onResolve({ filter }, (args) => ({
path: args.path,
namespace: "cm-external",
}));
build.onLoad(
{ filter: /.*/, namespace: "cm-external" },
(args) => ({
contents: `module.exports = window.CM['${args.path}']`,
loader: "js",
})
);
},
},
],
});
Key build requirements:
format: "iife"-- the bundle must be an immediately invoked function expression.globalName: "__cmFactory"-- the editor extracts the factory from this global variable.- The
cm-externalplugin -- prevents bundling duplicate CM6 code and ensures your extension shares the editor's CM6 instances.
Step 3: Register the extension
Via manifest
Declare file-based extensions in manifest.json:
{
"contributes": {
"codemirrorExtensions": [
{
"id": "my-ext.myPlugin",
"file": "cm-my-plugin.js",
"params": { "theme": "dark", "enableVim": true },
"fileExtensions": [".py", ".js"],
"description": "My custom editor plugin"
}
]
}
}
The file path is relative to the extension's root directory (typically the dist/ folder where the extension is installed).
Via SDK at runtime
import editor from "@srcnexus/ext-sdk";
await editor.extensions.registerCodeMirrorExtension("my-ext.myPlugin", {
file: "cm-my-plugin.js",
params: { theme: "dark", enableVim: true },
fileExtensions: [".py", ".js"],
description: "My custom editor plugin",
});
The SDK function signature for file-based mode is:
extensions.registerCodeMirrorExtension(
id: string,
fileOptions: {
file: string;
params?: any;
fileExtensions?: string[];
description?: string;
}
): Promise<void>
How file-based loading works
- The editor reads the JS file from the extension's local directory.
- Path security checks are applied (see Security constraints).
- The file content is stored internally for injection.
- When injected into the WebView, the bundled code executes and assigns to
var __cmFactory. - The editor extracts the default export (or the module itself) as a factory function.
- The factory is called with the
paramsvalue:factory(params). - The returned CM6 Extension is applied to the editor via
window.editor.extensions.register().
Manifest Reference
The codemirrorExtensions contribution in manifest.json accepts an array of objects with the following fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | -- | Unique identifier for this CM extension (e.g. "my-ext.vimMode") |
jsCode | string | Conditional | "" | JavaScript code returning a CM6 Extension (inline mode). Required if file is not set. |
file | string | Conditional | null | Relative path to a bundled .js file (file-based mode). Required if jsCode is not set. |
params | object | No | null | Parameters forwarded to the factory function (file-based mode only) |
description | string | No | null | Human-readable description of what this extension does |
fileExtensions | string[] | No | null | File extensions this applies to (e.g. [".js", ".ts"]). null or omitted means all files. |
Example: Combined manifest
{
"id": "editor-enhancements",
"name": "Editor Enhancements",
"version": "1.0.0",
"main": "main.js",
"contributes": {
"codemirrorExtensions": [
{
"id": "editor-enhancements.placeholder",
"jsCode": "return [];",
"fileExtensions": [".myext"],
"description": "Placeholder extension for .myext files"
},
{
"id": "editor-enhancements.vimMode",
"file": "cm-vim.js",
"fileExtensions": [".js", ".ts", ".py"],
"description": "Vim keybindings for code files"
},
{
"id": "editor-enhancements.globalTheme",
"file": "cm-theme.js",
"params": { "variant": "monokai" },
"description": "Custom global editor theme"
}
]
}
}
In this example:
- The first entry uses inline mode and applies only to
.myextfiles. - The second entry uses file-based mode and applies to
.js,.ts, and.pyfiles. - The third entry uses file-based mode with no
fileExtensionsfilter, so it applies to all files.
Runtime Registration via RPC
Extensions can register CodeMirror extensions programmatically at runtime using the editor.registerCodeMirrorExtension RPC method. This is what the SDK's extensions.registerCodeMirrorExtension() function calls under the hood.
RPC method: editor.registerCodeMirrorExtension
Inline mode parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique extension ID |
jsCode | string | Yes | JavaScript code returning a CM6 Extension |
fileExtensions | string[] | No | File type filter |
description | string | No | Human-readable description |
File-based mode parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique extension ID |
file | string | Yes | Relative path to the .js bundle |
params | object | No | Parameters for the factory function |
fileExtensions | string[] | No | File type filter |
description | string | No | Human-readable description |
When a CM extension is registered at runtime, it is immediately pushed to all already-open editor tabs whose file extension matches the filter. There is no need to close and reopen tabs.
File Extension Filtering
CodeMirror extensions can target specific file types using the fileExtensions field. The filtering behavior is:
fileExtensionsisnullor omitted -- the extension applies to all files.fileExtensionsis an empty array[]-- the extension applies to all files (same asnull).fileExtensionscontains values -- the extension applies only to files whose extension matches at least one entry.
The matching logic checks whether the file's extension ends with any of the specified values. Include the leading dot in each entry (e.g. ".js", not "js").
For dotfiles (files starting with . and having no other extension, like .gitignore), the entire filename is used as the extension for matching purposes.
Examples
"fileExtensions": [".js", ".ts", ".jsx", ".tsx"]
Applies only to JavaScript and TypeScript files.
"fileExtensions": [".py"]
Applies only to Python files.
"fileExtensions": null
Applies to all files regardless of type.
Security Constraints
The editor enforces several security measures for CodeMirror extension files:
File size limit
Both inline code and file-based bundles are limited to 3 MB (3,145,728 bytes). Attempts to register code exceeding this limit will throw an error.
File type restriction
File-based extensions must reference files ending with .js. Any other file extension is rejected.
Path traversal prevention
The file path specified in the file field is validated against path traversal attacks:
- Paths containing
..are rejected. - Paths containing backslashes (
\) are rejected. - After reading the file, a canonical path check verifies that the resolved path remains within the extension's directory. This prevents symlink-based escapes.
Sandboxed execution
The CM extension code runs inside the editor's WebView, which is sandboxed from the extension's main JavaScript worker. The CM code has access to window.CM (CodeMirror packages) and window.editor (editor API), but not to the extension SDK's RPC bridge.
Lifecycle
Activation
- Bootstrap -- When the app starts, the extension host iterates over all installed extensions. For each manifest-declared
codemirrorExtensionsentry:- Inline extensions are stored as-is.
- File-based extensions are resolved: the JS file is read from disk and stored internally.
- Tab open -- When a file tab is opened, the editor queries the registry for all CM extensions matching that file's extension and injects each one into the WebView.
- Runtime registration -- When
editor.registerCodeMirrorExtensionis called, the new extension is immediately pushed to all already-open tabs whose file type matches.
Deactivation
When an extension is uninstalled or disabled, the editor:
- Iterates over all open file tabs.
- Evaluates
window.editor.extensions.unregister(id)in each matching WebView to remove the CM extension. - Removes the contribution from the registry.
This happens automatically -- extension authors do not need to handle cleanup.
Diff Editor Compatibility
CodeMirror extensions registered via extensions.registerCodeMirrorExtension() are automatically applied to both the normal editor and the diff editor. The diff editor shares the same window.CM module map and window.editor.extensions API, so file-based extensions work without modification.
Detecting Diff Mode (window.__DIFF_MODE__)
A CodeMirror extension can check whether it's running inside the diff editor or the normal editor by reading the window.__DIFF_MODE__ flag:
// Inside a CodeMirror extension's JS code
const isDiffEditor = window.__DIFF_MODE__ === true;
if (isDiffEditor) {
// Diff editor — skip features that don't make sense in diff view
return [];
}
// Normal editor — return your extensions as usual
return [myExtension()];
The flag is an immutable boolean set before the page loads. It is true in the diff editor and undefined in the normal editor.
Use cases
- Skip extensions that don't apply to diffs (e.g. auto-complete, linting)
- Add diff-specific decorations (e.g. highlight conflict markers)
- Adjust behavior — an extension might want read-only mode in diffs but editable in normal editor
@codemirror/merge package
The diff editor also exposes @codemirror/merge on window.CM, so file-based extensions can access merge-specific APIs if needed:
const merge = window.CM['@codemirror/merge'];
See Diff Editor API for the full diff editor documentation.