/******************************************* * Monaco Editor PlantUML language features * ********************************************/ /** * Monaco Editor PlantUML Language Features. * * @example * ```js * plantumlFeatures = new PlantUmlLanguageFeatures(); * const model = monaco.editor.createModel(initCode, "apex", uri); * model.onDidChangeContent(() => plantumlFeatures.validateCode(model)); * ``` */ const PlantUmlLanguageFeatures = (function() { 'use strict'; /** * Create Monaco Editor PlantUML Language Features instance. * * @param {object} [options] global instance options */ function PlantUmlLanguageFeatures({ baseUrl = "", languageSelector = ["apex", "plantuml"], initialize = true } = {}) { const validationEventListeners = {}; // ========================================================================================================== // == PlantUML valdation methods == /** * Add validation event listener. * * Validation Event Order: * before -> code -> line -> after * * @param {("before"|"code"|"line"|"after")} type before|code|line|after event type * @param {(event: any) => Promise|editor.IMarkerData|Promise|editor.IMarkerData[]|Promise|void} listener event listener */ this.addValidationEventListener = (type, listener) => { if (!["before", "code", "line", "after"].includes(type)) { throw Error("Unknown validation event type: " + type); } validationEventListeners[type] = validationEventListeners[type] || []; validationEventListeners[type].push(listener); }; /** * Validate PlantUML language of monaco editor model. * * @param {editor.ITextModel} model editor model to validate * * @returns editor markers as promise * * @example * ```js * validateCode(editor.getModel()) * .then(markers => monaco.editor.setModelMarkers(model, "plantuml", markers)); * ``` */ this.validateCode = async (model) => { const promises = []; // raise before events promises.push(validationEventListeners.before?.map(listener => listener({ model }))); // raise code events promises.push(validationEventListeners.code?.map(listener => listener({ model, code: model.getValue() }))); if (validationEventListeners.line && validationEventListeners.line.length > 0) { // NOTE: lines and columns start at 1 const lineCount = model.getLineCount(); for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) { const range = { startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: model.getLineLength(lineNumber) + 1, }; const line = model.getValueInRange(range); // raise line events promises.push(validationEventListeners.line?.map(listener => listener({ model, range, line, lineNumber, lineCount }))); } } // raise after events promises.push(validationEventListeners.after?.map(listener => listener({ model }))); // collect all markers and ... // - since each event can results in an array of markers -> `flat(1)` // - since not each event has to results in markers and can be `undef return Promise.all(promises).then(results => results.flat(1).filter(marker => marker)); }; /** * Add PlantUML `@start` and `@end` command validation. */ this.addStartEndValidationListeners = () => { let diagramType = undefined; let startCounter = 0; let endCounter = 0; // reset validation cache this.addValidationEventListener("before", () => { diagramType = undefined; startCounter = 0; endCounter = 0; }); // @start should be the first command this.addValidationEventListener("code", ({ model, code }) => { const match = code.match(/^(('.*)|\s)*@start(?\w+)/); if (match) { diagramType = match.groups.type; return; // diagram code starts with a `@start` } return { message: "PlantUML diagrams should begin with the `@start` command and `@start` should also be the first command.", severity: monaco.MarkerSeverity.Warning, startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: model.getLineLength(1) + 1, }; }); // @end should be the last command and should be of the same type (e.g. @startjson ... @endjson) this.addValidationEventListener("code", ({ model, code }) => { const lineCount = model.getLineCount(); const match = code.match(/\s+@end(?\w+)(('.*)|\s)*$/); if (match) { if (diagramType === match.groups.type) { return; // diagram code ends with a `@end` of the same type as the `@start` } return { message: "PlantUML diagrams should start and end with the type.\nExample: `@startjson ... @endjson`", severity: monaco.MarkerSeverity.Error, startLineNumber: lineCount, startColumn: 1, endLineNumber: lineCount, endColumn: model.getLineLength(lineCount) + 1, }; } return { message: "PlantUML diagrams should end with the `@end` command and `@end` should also be the last command.", severity: monaco.MarkerSeverity.Warning, startLineNumber: lineCount, startColumn: 1, endLineNumber: lineCount, endColumn: model.getLineLength(lineCount) + 1, }; }); // @start should only be used once this.addValidationEventListener("line", ({ range, line }) => { const match = line.match(/^\s*@start(?\w+)(\s+.*)?$/); if (!match) return; startCounter += 1; if (startCounter > 1) { const word = "@start" + match.groups.type; const wordIndex = line.indexOf(word); return { message: "Multiple @start commands detected.", severity: monaco.MarkerSeverity.Warning, startLineNumber: range.startLineNumber, startColumn: wordIndex + 1, endLineNumber: range.endLineNumber, endColumn: wordIndex + word.length + 1, }; } }); // @end should only be used once this.addValidationEventListener("line", ({ range, line }) => { const match = line.match(/^\s*@end(?\w+)(\s+.*)?$/); if (!match) return; endCounter += 1; if (endCounter > 1) { const word = "@end" + match.groups.type; const wordIndex = line.indexOf(word); return { message: "Multiple @end commands detected.", severity: monaco.MarkerSeverity.Warning, startLineNumber: range.startLineNumber, startColumn: wordIndex + 1, endLineNumber: range.endLineNumber, endColumn: wordIndex + word.length + 1, }; } }); }; // ========================================================================================================== // == PlantUML code completion methods == this.registerThemeCompletion = () => { const createThemeProposals = async (range, filter = undefined) => { const themes = await this.getThemes(); return themes?.filter(theme => filter ? theme.includes(filter) : true) .map(theme => ({ label: theme, kind: monaco.languages.CompletionItemKind.Text, documentation: "PlantUML " + theme + " theme", insertText: theme, range: range, })) || []; }; monaco.languages.registerCompletionItemProvider(languageSelector, { triggerCharacters: [" "], provideCompletionItems: async (model, position) => { const textUntilPosition = model.getValueInRange({ startLineNumber: position.lineNumber, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column, }); if (textUntilPosition.match(/^\s*!(t(h(e(m(e)?)?)?)?)?$/)) { return { suggestions: [ { label: 'theme', kind: monaco.languages.CompletionItemKind.Keyword, documentation: "PlantUML theme command", insertText: 'theme', range: getWordRange(model, position), } ] }; } const match = textUntilPosition.match(/^\s*!theme\s+(?[^\s]*)$/); if (match) { const suggestions = await createThemeProposals(getWordRange(model, position), match.groups.theme); return { suggestions }; } return { suggestions: [] }; } }); }; this.registerIconCompletion = () => { const createIconProposals = async (range, filter = undefined) => { const icons = await this.getIcons(); return icons?.filter(icon => filter ? icon.includes(filter) : true) .map(icon => { // NOTE: markdown image path inside suggestions seems to have rendering issues while using relative paths const iconUrl = this.resolvePath(baseUrl + "ui-helper?request=icons.svg#" + icon); return { label: icon, kind: monaco.languages.CompletionItemKind.Constant, documentation: { //supportHtml: true, // also a possibility but quite limited html value: "![icon](" + iconUrl + ")   " + icon }, insertText: icon + ">", range: range }; }) || []; }; monaco.languages.registerCompletionItemProvider(languageSelector, { triggerCharacters: ["&"], provideCompletionItems: async (model, position) => { const textUntilPosition = model.getValueInRange({ startLineNumber: position.lineNumber, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column, }); const match = textUntilPosition.match(/<&(?[^\s>]*)$/); if (match) { const suggestions = await createIconProposals(getWordRange(model, position), match.groups.icon); return { suggestions }; } return { suggestions: [] }; } }); }; this.registerEmojiCompletion = () => { const createEmojiProposals = async (range, filter = undefined) => { const emojis = await this.getEmojis(); return emojis?.filter(([unicode, name]) => filter ? unicode.includes(filter) || name?.includes(filter) : true) .map(([unicode, name]) => { // NOTE: load images direct from GitHub source: https://github.com/twitter/twemoji#download const emojiUrl = "https://raw.githubusercontent.com/twitter/twemoji/gh-pages/v/13.1.0/svg/" + unicode + ".svg"; const docHint = (name) ? name + " (" + unicode + ")" : unicode; const isUnicode = !name || (filter && unicode.includes(filter)); const label = isUnicode ? unicode : name; return { label: label, kind: monaco.languages.CompletionItemKind.Constant, documentation: { //supportHtml: true, // also a possibility but quite limited html value: "![emoji](" + emojiUrl + ")   " + docHint }, insertText: label + ":>", range: range }; }) || []; }; monaco.languages.registerCompletionItemProvider(languageSelector, { triggerCharacters: [":"], provideCompletionItems: async (model, position) => { const textUntilPosition = model.getValueInRange({ startLineNumber: position.lineNumber, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column, }); const match = textUntilPosition.match(/<:(?[^\s>]*)$/); if (match) { const suggestions = await createEmojiProposals(getWordRange(model, position), match.groups.emoji); return { suggestions }; } return { suggestions: [] }; } }); }; // ========================================================================================================== // == helper methods == this.resolvePath = (path) => { if (path.startsWith("http")) return path; if (path.startsWith("/")) return window.location.origin + path; if (path.slice(0, 2) == "./") path = path.slice(2); let base = (document.querySelector("base") || {}).href || window.location.origin; if (base.slice(-1) == "/") base = base.slice(0, -1); return base + "/" + path; }; this.getIcons = (function(){ let icons = undefined; return async () => { if (icons === undefined) { icons = await makeRequest("GET", "ui-helper?request=icons", { responseType: "json" }); } return icons; } })(); this.getEmojis = (function(){ let emojis = undefined; return async () => { if (emojis === undefined) { emojis = await makeRequest("GET", "ui-helper?request=emojis", { responseType: "json" }); } return emojis; } })(); this.getThemes = (function(){ let themes = undefined; return async () => { if (themes === undefined) { themes = await makeRequest("GET", "ui-helper?request=themes", { responseType: "json" }); } return themes; } })(); const makeRequest = ( method, url, { data = null, headers = { "Content-Type": "text/plain" }, responseType = "text", ignoreBaseUrl = false } = {} ) => { const targetUrl = (ignoreBaseUrl === true) ? url : baseUrl + url; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.status >= 200 && xhr.status <= 300) { if (responseType === "json") { resolve(xhr.response); } else { resolve(xhr.responseText); } } else { if (responseType === "json") { reject({ status: xhr.status, response: xhr.response }); } else { reject({ status: xhr.status, responseText: xhr.responseText }); } } } } xhr.open(method, targetUrl, true); xhr.responseType = responseType; headers && Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key])); xhr.send(data); }); }; const getWordRange = (model, position) => { const word = model.getWordUntilPosition(position); return { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; } // ========================================================================================================== // == constructor running code == // prepare base URL if (baseUrl === null || baseUrl === undefined) { baseUrl = ""; } else if (baseUrl !== "") { // add tailing "/" if (baseUrl.slice(-1) !== "/") baseUrl = baseUrl + "/"; } // initialize default validation and code completion if (initialize) { this.addStartEndValidationListeners(); this.registerThemeCompletion(); this.registerIconCompletion(); this.registerEmojiCompletion(); } } return PlantUmlLanguageFeatures; })();