Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This project makes an ESM bundle of monaco-editor with 50 themes and localizatio

## Notes
* A bug / issue I had to handle is: when changing mode, html worker would attempt to get links from document, but occasionally `document` would be `null` and an exception would be thrown. This has been handled by adding `if(!document) return []` at the top of `function findDocumentLinks(...)` in file `htmlLinks.js`
* The build adds `monaco.css.getWorker(languageId)` and `monaco.html.getWorker(languageId)`, like monaco's own `monaco.json.getWorker()`, so the editor can use the CSS and HTML language services for embedded content (e.g. a `<style>` block in HTML). `languageId` defaults to `css` / `html`; `scss`, `less`, `handlebars`, `razor` and ids passed to `monaco.html.registerHTMLLanguageService()` work too. Each resolves to `(...uris) => Promise<worker>` once that language has been used. See `exposeLanguageWorkers()` in `generate-monaco-esm-i18n.js`; the build fails if monaco's code has moved and the patch no longer applies. A language added with `registerHTMLLanguageService()` asks `MonacoEnvironment.getWorkerUrl` for a worker labelled with its own id, so map that label to `html` there.

## Instructions

Expand Down
77 changes: 77 additions & 0 deletions generate-monaco-esm-i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ function injectSourcePath(monacoVersion, callback) {
}
}
});
try {
exposeLanguageWorkers();
} catch (err) {
callback(err);
return;
}
callback();
});
});
Expand All @@ -128,6 +134,77 @@ function injectSourcePath(monacoVersion, callback) {
})
}

/**
* Replace text in a file and fail the build unless it matched exactly `expected` times, so a monaco
* update that moves the code can't silently drop the patch.
*/
function patchExactly(file, from, to, expected = 1) {
// replace-in-file counts with String.match(), which only counts every match for a global regex
from = from.global ? from : new RegExp(from.source, from.flags + "g");
const [result] = replaceInFileSync({ files: file, from, to, countMatches: true });
const found = result ? result.numMatches : 0;
if (found !== expected) {
throw new Error(`nr-monaco-build: expected ${expected} match(es) of ${from} in ${file} but found ${found}. ` +
"monaco has changed, update exposeLanguageWorkers() in generate-monaco-esm-i18n.js");
}
}

/**
* Expose the CSS and HTML language workers, the same way monaco already does for JSON
* (`monaco.json.getWorker()`) and TypeScript (`monaco.typescript.getJavaScriptWorker()`), so editor
* code can use those language services for embedded content, e.g. a <style> block inside HTML:
*
* monaco.css.getWorker(languageId = "css") // "css", "scss" or "less"
* monaco.html.getWorker(languageId = "html") // "html", "handlebars", "razor" or an id passed to
* // monaco.html.registerHTMLLanguageService()
*
* Each resolves to `(...uris) => Promise<worker>`, like `monaco.json.getWorker()`, and rejects with
* "<languageId> not registered!" until that language has been used (its worker starts lazily).
* The worker methods (doComplete, doHover, doValidation, findDocumentColors, ...) are monaco's
* CSSWorker / HTMLWorker classes in esm/vs/languages/features/{css,html}/*Worker.js.
*/
function exposeLanguageWorkers() {
const registry = [
"",
"// Added by nr-monaco-build: expose the language workers, like monaco.json.getWorker()",
"const nrWorkers = new Map();",
"function nrRegisterWorker(languageId, worker, disposables) {",
" nrWorkers.set(languageId, worker);",
" disposables?.push({ dispose: () => { if (nrWorkers.get(languageId) === worker) { nrWorkers.delete(languageId); } } });",
"}",
"function getWorker(languageId) {",
" return new Promise((resolve, reject) => {",
" const worker = nrWorkers.get(languageId);",
" if (!worker) {",
" return reject(languageId + \" not registered!\");",
" }",
" resolve(worker);",
" });",
"}",
""
].join("\n");
const featuresDir = path.join(monacoModEsmDir, "vs", "languages", "features");
// htmlMode.js has two setup functions (setupMode and the older setupMode1), cssMode.js has one
for (const [lang, setupCount] of [["css", 1], ["html", 2]]) {
const modeFile = path.join(featuresDir, lang, `${lang}Mode.js`);
// 1. remember each language's worker accessor when its mode is set up
patchExactly(modeFile,
/(\n[ \t]*const worker = \(\.\.\.uris\) => \{\s*return client\.getLanguageServiceWorker\(\.\.\.uris\);\s*\};)/g,
`$1\n nrRegisterWorker(defaults.languageId, worker, typeof disposables === "undefined" ? void 0 : disposables);`,
setupCount);
// 2. add the registry and export getWorker
patchExactly(modeFile, /\nexport \{ ([^}]*) \};\s*$/, `\n${registry}\nexport { $1, getWorker };\n`);
// 3. public API: monaco.css.getWorker / monaco.html.getWorker
patchExactly(path.join(featuresDir, lang, "register.js"), /\nexport \{ ([^}]*) \};\s*$/,
`\nconst getWorker = (languageId = "${lang}") => getMode().then((mode2) => mode2.getWorker(languageId));\nexport { $1, getWorker };\n`);
// 4. older import path (esm/vs/language/<lang>/monaco.contribution.js) re-exports by name
const contribution = path.join(monacoModEsmDir, "vs", "language", lang, "monaco.contribution.js");
if (fs.existsSync(contribution)) {
patchExactly(contribution, /export \{ ([^}]*) \} from '([^']*\/register\.js)';/, "export { $1, getWorker } from '$2';");
}
}
}


async function main() {
const pkg = await readPackage("package.json");
Expand Down
67 changes: 67 additions & 0 deletions test/browser/editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,73 @@ describe("built editor in a browser", () => {
await page.close();
});

it("exposes the css and html workers (monaco.css.getWorker, monaco.html.getWorker)", { timeout: 120000 }, async () => {
// a worker that fails to start makes its calls wait forever, so this test has its own timeout
// Added by generate-monaco-esm-i18n.js exposeLanguageWorkers(), the same shape as monaco.json.getWorker()
const { page, errors } = await openEditor();
const result = await page.evaluate(async () => {
const waitFor = async (fn, ms = 20000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn().catch(() => null);
if (v) return v;
await new Promise((r) => setTimeout(r, 100));
}
return null;
};
const api = { css: typeof monaco.css.getWorker, html: typeof monaco.html.getWorker };
// a language that hasn't been used yet has no worker
const unused = await monaco.css.getWorker("less").then(() => "resolved", (e) => String(e));

const css = monaco.editor.createModel("body { col }", "css");
const cssWorker = await waitFor(async () => (await monaco.css.getWorker())(css.uri));
const cssItems = (await cssWorker.doComplete(css.uri.toString(), { line: 0, character: 10 })).items.map((i) => i.label);
const colorModel = monaco.editor.createModel(".a { color: #ff0000; }", "css");
const colors = await (await (await monaco.css.getWorker())(colorModel.uri)).findDocumentColors(colorModel.uri.toString());

const scss = monaco.editor.createModel("$c: red;\n.a { color: $c; }", "scss");
const scssWorker = await waitFor(async () => (await monaco.css.getWorker("scss"))(scss.uri));
const scssHover = await scssWorker.doHover(scss.uri.toString(), { line: 1, character: 7 });

const html = monaco.editor.createModel("<di", "html");
const htmlWorker = await waitFor(async () => (await monaco.html.getWorker())(html.uri));
const htmlItems = (await htmlWorker.doComplete(html.uri.toString(), { line: 0, character: 3 })).items.map((i) => i.label);

// a language added with registerHTMLLanguageService gets its own worker too. Monaco asks
// MonacoEnvironment for a worker labelled with that language id, which only knows the built-in
// labels, so map it to the html worker (node-red's getWorkerUrl needs the same for such languages)
const env = self.MonacoEnvironment;
const getWorkerUrl = env.getWorkerUrl;
env.getWorkerUrl = (moduleId, label) => getWorkerUrl.call(env, moduleId, label === "nr-test-html" ? "html" : label);
monaco.languages.register({ id: "nr-test-html" });
monaco.html.registerHTMLLanguageService("nr-test-html");
const custom = monaco.editor.createModel("<sp", "nr-test-html");
const customWorker = await waitFor(async () => (await monaco.html.getWorker("nr-test-html"))(custom.uri));
const customItems = (await customWorker.doComplete(custom.uri.toString(), { line: 0, character: 3 })).items.map((i) => i.label);

return {
api,
unused,
cssCompletesColor: cssItems.includes("color"),
cssColors: colors.length,
scssHover: !!scssHover,
htmlCompletesDiv: htmlItems.includes("div"),
customCompletesSpan: customItems.includes("span"),
};
});
assert.deepEqual(result, {
api: { css: "function", html: "function" },
unused: "less not registered!",
cssCompletesColor: true,
cssColors: 1,
scssHover: true,
htmlCompletesDiv: true,
customCompletesSpan: true,
});
assert.deepEqual(errors, []);
await page.close();
});

it("translates the UI for every locale", async () => {
const label = async (lang) => {
const { page, errors } = await openEditor(lang);
Expand Down