microsoft / microsoft/monaco-editor
[Feature Request] monaco-esbuild-plugin
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 46.8k
- Forks
- 4.1k
- Avg merge
- 17h 58m
- Merged PRs (30d)
- 1
Description
Context
- This issue is not a bug report. (please use a different template for reporting a bug)
- This issue is not a duplicate of an existing issue. (please use the search to find existing issues)
Description
Monaco Editor integration with webpack is straightforward, allowing developers to easily configure the languages and features to include. With frameworks like Angular transitioning to esbuild, a similar plugin for esbuild would be great.
We developed our own esbuild plugin, which could serve as a starting point:
import * as path from 'path';
import * as fs from 'fs';
import * as crypto from 'crypto';
import { Plugin, PluginBuild } from 'esbuild';
const EDITOR_MODULE: IFeatureDefinition = {
label: 'editorWorkerService',
entry: undefined,
worker: {
id: 'vs/editor/editor',
entry: 'vs/editor/editor.worker'
}
};
interface IWorkerDefinition {
id: string;
entry: string;
}
interface IFeatureDefinition {
label: string;
entry: string | string[] | undefined;
worker?: IWorkerDefinition;
}
interface ILoaderOptions {
globals?: { [key: string]: string };
pre?: string[];
post?: string[];
}
interface IMonacoEditorEsbuildPluginOpts {
languages?: string[];
customLanguages?: IFeatureDefinition[];
features?: string[];
filename?: string;
monacoEditorPath?: string;
globalAPI?: boolean;
}
interface ILabeledWorkerDefinition {
label: string;
id: string;
entry: string;
}
const resolveMonacoPath = (filePath: string, monacoEditorPath: string | undefined): string => {
if (monacoEditorPath) {
return require.resolve(path.join(monacoEditorPath, 'esm', filePath));
}
try {
return require.resolve(path.join('monaco-editor/esm', filePath));
} catch (err) {}
try {
return require.resolve(path.join(process.cwd(), 'node_modules/monaco-editor/esm', filePath));
} catch (err) {}
return require.resolve(filePath);
};
const getWorkerFilename = (filename: string, entry: string, monacoEditorPath: string | undefined): string => {
const content = fs.readFileSync(resolveMonacoPath(entry, monacoEditorPath));
const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
return filename.replace(/\[name\]/g, path.basename(entry, path.extname(entry))).replace(/\[contenthash\]/g, hash);
};
const getEditorMetadata = (monacoEditorPath: string | undefined): any => {
const metadataPath = resolveMonacoPath('metadata.js', monacoEditorPath);
return require(metadataPath);
};
const resolveDesiredFeatures = (
metadata: any,
userFeatures: string[] | undefined
): IFeatureDefinition[] => {
const featuresById: { [feature: string]: IFeatureDefinition } = {};
metadata.features.forEach((feature: IFeatureDefinition) => (featuresById[feature.label] = feature));
function notContainedIn(arr: string[]) {
return (element: string) => arr.indexOf(element) === -1;
}
let featuresIds: string[];
if (userFeatures && userFeatures.length) {
const excludedFeatures = userFeatures.filter((f) => f[0] === '!').map((f) => f.slice(1));
if (excludedFeatures.length) {
featuresIds = Object.keys(featuresById).filter(notContainedIn(excludedFeatures));
} else {
featuresIds = userFeatures;
}
} else {
featuresIds = Object.keys(featuresById);
}
return coalesce(featuresIds.map((id) => featuresById[id]));
};
const resolveDesiredLanguages = (
metadata: any,
userLanguages: string[] | undefined,
userCustomLanguages: IFeatureDefinition[] | undefined
): IFeatureDefinition[] => {
const languagesById: { [language: string]: IFeatureDefinition } = {};
metadata.languages.forEach((language: IFeatureDefinition) => (languagesById[language.label] = language));
const languages = userLanguages || Object.keys(languagesById);
return coalesce(languages.map((id) => languagesById[id])).concat(userCustomLanguages || []);
};
const coalesce = <T>(array: ReadonlyArray<T | undefined | null>): T[] => {
return array.filter(Boolean) as T[];
};
const flatArr = <T>(items: (T | T[])[]): T[] => {
return items.reduce((acc: T[], item: T | T[]) => {
if (Array.isArray(item)) {
return acc.concat(item);
}
return acc.concat([item]);
}, []);
};
const fromPairs = <T>(values: [string, T][]): { [key: string]: T } => {
return values.reduce(
(acc, [key, value]) => Object.assign(acc, { [key]: value }),
{} as { [key: string]: T }
);
};
const monacoEditorEsbuildPlugin = (options: IMonacoEditorEsbuildPluginOpts = {}): Plugin => {
const monacoEditorPath = options.monacoEditorPath;
const metadata = getEditorMetadata(monacoEditorPath);
const languages = resolveDesiredLanguages(metadata, options.languages, options.customLanguages);
const features = resolveDesiredFeatures(metadata, options.features);
const filename = options.filename || '[name].worker.js';
const globalAPI = options.globalAPI || false;
return {
name: 'monaco-editor-esbuild-plugin',
setup(build) {
const modules = [EDITOR_MODULE].concat(languages).concat(features);
const workers: ILabeledWorkerDefinition[] = [];
modules.forEach((module) => {
if (module.worker) {
workers.push({
label: module.label,
id: module.worker.id,
entry: module.worker.entry,
});
}
});
// Handle worker files
const workerEntries = workers.reduce((acc, w) => {
acc[w.entry] = resolveMonacoPath(w.entry, undefined);
return acc;
}, {} as Record<string, string>);
const workerBuildResult = buildWorkers(build, workerEntries);
const namesWithHash = fromPairs(Object.entries(workerBuildResult.metafile.outputs).map(([key, value]) => {
return [path.parse(value.entryPoint).name, key];
}));
const workerPaths = fromPairs(
workers.map(({ label, entry }) => [label, getWorkerFilename(filename, entry, monacoEditorPath)])
);
if (workerPaths['typescript']) {
// javascript shares the same worker
workerPaths['javascript'] = workerPaths['typescript'];
}
if (workerPaths['css']) {
// scss and less share the same worker
workerPaths['less'] = workerPaths['css'];
workerPaths['scss'] = workerPaths['css'];
}
if (workerPaths['html']) {
// handlebars, razor and html share the same worker
workerPaths['handlebars'] = workerPaths['html'];
workerPaths['razor'] = workerPaths['html'];
}
Object.entries(workerPaths).forEach(([key, value]) => {
workerPaths[key] = namesWithHash[path.parse(value).name];
});
build.onEnd((result) => {
const { outputFiles, metafile } = workerBuildResult;
if (outputFiles?.length) {
result.outputFiles?.push(...outputFiles);
}
if (result.metafile && metafile) {
Object.assign(result.metafile.inputs, metafile.inputs);
Object.assign(result.metafile.outputs, metafile.outputs);
}
});
// Handle monaco editor imports
build.onResolve({ filter: /esm[/\\]vs[/\\]editor[/\\]editor.(api|main)/ }, (args) => {
return {
path: args.path,
namespace: 'monaco',
};
});
build.onLoad({ filter: /.*/, namespace: 'monaco' }, async (args) => {
const stringifyRequest = (request: string) => {
let relativePath = path.relative('node_modules/monaco-editor/esm/vs/editor', request);
// Normalize Windows paths
if (path.sep === '\\') {
relativePath = relativePath.replace(/\\/g, '/');
}
if (!relativePath.startsWith('.')) {
relativePath = './' + relativePath;
}
return JSON.stringify(relativePath);
};
const contents = `
self["MonacoEnvironment"] = (function (paths) {
return {
globalAPI: ${globalAPI},
getWorkerUrl: function (moduleId, label) {
return paths[label];
}
};
})(${JSON.stringify(workerPaths, null, 2)});
${flatArr(coalesce(features.map((feature) => feature.entry))).map((entry) => `import ${stringifyRequest(resolveMonacoPath(entry, monacoEditorPath))};`).join('\n')}
import * as monaco from "./editor.api.js";
export * from "./editor.api.js";
export default monaco;
${flatArr(coalesce(languages.map((language) => language.entry))).map((entry) => `import ${stringifyRequest(resolveMonacoPath(entry, monacoEditorPath))};`).join('\n')}
export { WorkerManager } from "../language/typescript/tsMode";
export { CommandsRegistry } from '../platform/commands/common/commands';
`;
return {
contents,
loader: 'js',
resolveDir: 'node_modules/monaco-editor/esm/vs/editor',
};
});
},
};
};
function buildWorkers(build: PluginBuild, entryPoints: Record<string, string>) {
return build.esbuild.buildSync({
platform: 'browser',
write: false,
bundle: true,
metafile: true,
outdir: '.',
format: 'iife',
entryPoints,
loader: { '.ttf': 'file', '.css': 'css' },
sourcemap: false,
supported: undefined,
plugins: undefined,
splitting: false,
entryNames: '[name]-[hash]',
});
}
const monacoPlugin = monacoEditorEsbuildPlugin({
languages: ['javascript', 'json', 'typescript', 'html', 'css', 'markdown'],
});
export default monacoPlugin;
For Angular projects, this plugin can be used with the custom-esbuild builder.
With this approach we had to export WorkerManager and CommandsRegistry for registering a custom language.
Monaco Editor Playground Link
No response
Monaco Editor Playground Code
No response
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Use the proposed monacoEditorEsbuildPlugin implementation and the linked custom-esbuild Angular builder as starting references. Review Monaco Editor’s existing webpack integration, then identify the package entry point and tests needed for an esbuild equivalent. The issue does not specify target files or acceptance tests, so the definition of done needs clarification.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- build-system, frontend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100