Frontend Development 24 min read

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.

NetEase Cloud Music Tech Team
NetEase Cloud Music Tech Team
NetEase Cloud Music Tech Team
Building a Lightweight WebIDE with Monaco Editor: Multi‑File Support, ESLint, Prettier, and Code Completion

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.

frontendJavaScriptcode completionMonaco EditorWebIDEESLintPrettier
NetEase Cloud Music Tech Team
Written by

NetEase Cloud Music Tech Team

Official account of NetEase Cloud Music Tech Team

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.