Building a Lightweight WebIDE with Monaco Editor: Multi‑File Support, ESLint, Prettier, and Code Completion
The guide shows how to build a lightweight, browser‑based WebIDE using Monaco Editor by adding multi‑file support, preserving view state, integrating ESLint via a WebWorker, enabling Prettier formatting, and extending TypeScript definitions for rich code completion and navigation.
The team is developing a low‑code platform that will support both LowCode and ProCode modes. The ProCode scenario requires a feature‑rich WebIDE running in the browser, and the WebIDE component is extracted for future customization.
Monaco‑editor provides a solid foundation for a WebIDE, but adding multi‑file handling, ESLint, Prettier, and code‑completion is non‑trivial.
Introducing monaco‑editor
Monaco can be loaded via AMD or ESM. Due to an issue with the ESM build, the project finally adopts the AMD approach: a script tag is injected, and a polling timer checks window.monaco until the editor is ready.
Multi‑file support
Monaco’s API monaco.editor.create and monaco.editor.createModel enable handling multiple files. A model is created per file path, and editor.setModel switches the active file.
const files = {
'/test.js': 'xxx',
'/app/test.js': 'xxx2',
};
const editor = monaco.editor.create(domNode, {
...options,
model: null, // prevent default empty model
});
Object.keys(files).forEach(path => {
monaco.editor.createModel(
files[path],
'javascript',
new monaco.Uri().with({ path })
);
});
function openFile(path) {
const model = monaco.editor.getModels().find(m => m.uri.path === path);
editor.setModel(model);
}
openFile('/test.js');Preserving state before switch
To keep scroll position and selections when switching files, a Map stores each file’s view state.
const editorStatus = new Map();
let preFilePath = '';
function openFile(path) {
const model = monaco.editor.getModels().find(m => m.uri.path === path);
if (path !== preFilePath) {
editorStatus.set(preFilePath, editor.saveViewState());
}
editor.setModel(model);
const editorState = editorStatus.get(path);
if (editorState) {
editor.restoreViewState(editorState);
}
editor.focus();
preFilePath = path;
}File navigation (cmd+click)
Monaco does not navigate on cmd+click by default. Overriding openCodeEditor allows custom navigation.
const editorService = editor._codeEditorService;
const openEditorBase = editorService.openCodeEditor.bind(editorService);
editorService.openCodeEditor = async (input, source) => {
const result = await openEditorBase(input, source);
if (result === null) {
const fullPath = input.resource.path;
source.setModel(monaco.editor.getModel(input.resource));
source.setSelection(input.options.selection);
source.revealLine(input.options.selection.startLineNumber);
}
return result;
};ESLint support
ESLint’s core Linter can be bundled for the browser. The workflow is:
Bundle ESLint.js (including custom rules).
Instantiate new Linter() and define any extra rules.
Call linter.verify(code, config) on each change.
To avoid UI jank, linting runs inside a WebWorker, triggered by model.onDidChangeContent with debouncing. The worker returns markers that are applied to the model.
// Main thread
worker.onmessage = function(event) {
const { markers, version } = event.data;
const model = editor.getModel();
if (model && model.getVersionId() === version) {
window.monaco.editor.setModelMarkers(model, 'ESLint', markers);
}
};
let timer = null;
model.onDidChangeContent(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
worker.postMessage({
code: model.getValue(),
version: model.getVersionId(),
path,
});
}, 500);
}); // Worker side
self.addEventListener('message', function(e) {
const { code, version, path } = e.data;
const ext = getExtName(path);
if (!['js', 'jsx'].includes(ext)) {
self.postMessage({ markers: [], version });
return;
}
const errs = self.linter.esLinter.verify(code, config);
const markers = errs.map(err => ({
code: { value: err.ruleId, target: ruleDefines.get(err.ruleId).meta.docs.url },
startLineNumber: err.line,
endLineNumber: err.endLine,
startColumn: err.column,
endColumn: err.endColumn,
message: err.message,
severity: severityMap[err.severity],
source: 'ESLint',
}));
self.postMessage({ markers, version });
});Prettier support
Prettier works in the browser out of the box. The editor registers a document‑formatting provider that calls Prettier.format with the file path and appropriate parser plugins.
function provideDocumentFormattingEdits(model) {
const p = window.require('Prettier');
const text = p.Prettier.format(model.getValue(), {
filepath: model.uri.path,
plugins: p.PrettierPlugins,
singleQuote: true,
tabWidth: 4,
});
return [{ range: model.getFullModelRange(), text }];
}
monaco.languages.registerDocumentFormattingEditProvider('javascript', { provideDocumentFormattingEdits });
monaco.languages.registerDocumentFormattingEditProvider('css', { provideDocumentFormattingEdits });
monaco.languages.registerDocumentFormattingEditProvider('less', { provideDocumentFormattingEdits });The AMD loader defines Prettier and its parsers from CDN URLs, keeping bundle size low.
window.define('Prettier', [
'https://unpkg.com/[email protected]/standalone.js',
'https://unpkg.com/[email protected]/parser-babel.js',
'https://unpkg.com/[email protected]/parser-html.js',
'https://unpkg.com/[email protected]/parser-postcss.js',
'https://unpkg.com/[email protected]/parser-typescript.js'
], (Prettier, ...args) => {
const PrettierPlugins = {
babel: args[0],
html: args[1],
postcss: args[2],
typescript: args[3],
};
return { Prettier, PrettierPlugins };
});Formatting can be triggered via the built‑in command editor.action.formatDocument or bound to cmd+s .
// Save shortcut
editor.getAction('editor.action.formatDocument').run();Code completion
Monaco already offers basic completions, but not for third‑party libraries like React. Adding the type definitions via addExtraLib to the TypeScript defaults provides rich completions and jump‑to‑definition.
window.monaco.languages.typescript.javascriptDefaults.addExtraLib(
'content of react/index.d.ts',
'music:/node_modules/@types/react/index.d.ts'
);The added .d.ts files also create hidden models, enabling cmd+click navigation to the definition.
Theme replacement
Because Monaco’s parser differs from VSCode’s, the article references an external guide for applying VSCode themes to Monaco.
Preview sandbox
In the company’s internal setup, the WebIDE is paired with a CodeSandbox‑based sandbox for previewing the result. A lightweight module‑based preview is also possible via a service worker, but handling node_modules remains a challenge.
Conclusion
The article demonstrates that, with Monaco’s APIs, a functional WebIDE can be assembled quickly. It covers multi‑file handling, browser‑based ESLint, Prettier integration, and code‑completion strategies. The provided source code and demo can be used as a starting point for similar projects.
Source code is available on GitHub; readers are encouraged to star the repository.
NetEase Cloud Music Tech Team
Official account of NetEase Cloud Music Tech Team
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.