Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature Request] monaco-esbuild-plugin #4614

Open
2 tasks done
ckorherr opened this issue Jul 26, 2024 · 0 comments
Open
2 tasks done

[Feature Request] monaco-esbuild-plugin #4614

ckorherr opened this issue Jul 26, 2024 · 0 comments
Labels
feature-request Request for new features or functionality

Comments

@ckorherr
Copy link

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

@ckorherr ckorherr added the feature-request Request for new features or functionality label Jul 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature-request Request for new features or functionality
Projects
None yet
Development

No branches or pull requests

1 participant