Commit 4b724979 authored by Denys Mishunov's avatar Denys Mishunov

Merge branch '212279-webide-vue-files' into 'master'

Introduce syntax highlighting for .vue files

See merge request gitlab-org/gitlab!30986
parents fabb7dc3 f4789c5c
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
import languages from '~/ide/lib/languages';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
import { registerLanguages } from '~/ide/utils';
import { clearDomElement } from './utils';
export default class Editor {
......@@ -17,6 +19,8 @@ export default class Editor {
};
Editor.setupMonacoTheme();
registerLanguages(...languages);
}
static setupMonacoTheme() {
......
......@@ -7,8 +7,10 @@ import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import { themes } from './themes';
import languages from './languages';
import keymap from './keymap.json';
import { clearDomElement } from '~/editor/utils';
import { registerLanguages } from '../utils';
function setupThemes() {
themes.forEach(theme => {
......@@ -37,6 +39,7 @@ export default class Editor {
};
setupThemes();
registerLanguages(...languages);
this.debouncedUpdate = debounce(() => {
this.updateDimensions();
......
import vue from './vue';
const languages = [vue];
export default languages;
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
*--------------------------------------------------------------------------------------------*/
// Based on handlebars template in https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts
// Look for "vuejs template attributes" in this file for Vue specific syntax.
import { languages } from 'monaco-editor';
/* eslint-disable no-useless-escape */
/* eslint-disable @gitlab/require-i18n-strings */
const EMPTY_ELEMENTS = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'menuitem',
'meta',
'param',
'source',
'track',
'wbr',
];
const conf = {
wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,
comments: {
blockComment: ['{{!--', '--}}'],
},
brackets: [['<!--', '-->'], ['<', '>'], ['{{', '}}'], ['{', '}'], ['(', ')']],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: '<', close: '>' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
onEnterRules: [
{
beforeText: new RegExp(
`<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
'i',
),
afterText: /^<\/(\w[\w\d]*)\s*>$/i,
action: { indentAction: languages.IndentAction.IndentOutdent },
},
{
beforeText: new RegExp(
`<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
'i',
),
action: { indentAction: languages.IndentAction.Indent },
},
],
};
const language = {
defaultToken: '',
tokenPostfix: '',
// ignoreCase: true,
// The main tokenizer for our languages
tokenizer: {
root: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.root' }],
[/<!DOCTYPE/, 'metatag.html', '@doctype'],
[/<!--/, 'comment.html', '@comment'],
[/(<)([\w]+)(\/>)/, ['delimiter.html', 'tag.html', 'delimiter.html']],
[/(<)(script)/, ['delimiter.html', { token: 'tag.html', next: '@script' }]],
[/(<)(style)/, ['delimiter.html', { token: 'tag.html', next: '@style' }]],
[/(<)([:\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
[/(<\/)([\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
[/</, 'delimiter.html'],
[/\{/, 'delimiter.html'],
[/[^<{]+/], // text
],
doctype: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
[/[^>]+/, 'metatag.content.html'],
[/>/, 'metatag.html', '@pop'],
],
comment: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
[/-->/, 'comment.html', '@pop'],
[/[^-]+/, 'comment.content.html'],
[/./, 'comment.content.html'],
],
otherTag: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.otherTag' }],
[/\/?>/, 'delimiter.html', '@pop'],
// -- BEGIN vuejs template attributes
[/(v-|@|:)[\w\-\.\:\[\]]+="([^"]*)"/, 'variable'],
[/(v-|@|:)[\w\-\.\:\[\]]+='([^']*)'/, 'variable'],
[/"([^"]*)"/, 'attribute.value'],
[/'([^']*)'/, 'attribute.value'],
[/[\w\-\.\:\[\]]+/, 'attribute.name'],
// -- END vuejs template attributes
[/=/, 'delimiter'],
[/[ \t\r\n]+/], // whitespace
],
// -- BEGIN <script> tags handling
// After <script
script: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.script' }],
[/type/, 'attribute.name', '@scriptAfterType'],
[/"([^"]*)"/, 'attribute.value'],
[/'([^']*)'/, 'attribute.value'],
[/[\w\-]+/, 'attribute.name'],
[/=/, 'delimiter'],
[
/>/,
{
token: 'delimiter.html',
next: '@scriptEmbedded.text/javascript',
nextEmbedded: 'text/javascript',
},
],
[/[ \t\r\n]+/], // whitespace
[
/(<\/)(script\s*)(>)/,
['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
],
],
// After <script ... type
scriptAfterType: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterType' }],
[/=/, 'delimiter', '@scriptAfterTypeEquals'],
[
/>/,
{
token: 'delimiter.html',
next: '@scriptEmbedded.text/javascript',
nextEmbedded: 'text/javascript',
},
], // cover invalid e.g. <script type>
[/[ \t\r\n]+/], // whitespace
[/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
],
// After <script ... type =
scriptAfterTypeEquals: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterTypeEquals' }],
[/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
[/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
[
/>/,
{
token: 'delimiter.html',
next: '@scriptEmbedded.text/javascript',
nextEmbedded: 'text/javascript',
},
], // cover invalid e.g. <script type=>
[/[ \t\r\n]+/], // whitespace
[/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
],
// After <script ... type = $S2
scriptWithCustomType: [
[
/\{\{/,
{ token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptWithCustomType.$S2' },
],
[/>/, { token: 'delimiter.html', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }],
[/"([^"]*)"/, 'attribute.value'],
[/'([^']*)'/, 'attribute.value'],
[/[\w\-]+/, 'attribute.name'],
[/=/, 'delimiter'],
[/[ \t\r\n]+/], // whitespace
[/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
],
scriptEmbedded: [
[
/\{\{/,
{
token: '@rematch',
switchTo: '@handlebarsInEmbeddedState.scriptEmbedded.$S2',
nextEmbedded: '@pop',
},
],
[/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
],
// -- END <script> tags handling
// -- BEGIN <style> tags handling
// After <style
style: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.style' }],
[/type/, 'attribute.name', '@styleAfterType'],
[/"([^"]*)"/, 'attribute.value'],
[/'([^']*)'/, 'attribute.value'],
[/[\w\-]+/, 'attribute.name'],
[/=/, 'delimiter'],
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }],
[/[ \t\r\n]+/], // whitespace
[
/(<\/)(style\s*)(>)/,
['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
],
],
// After <style ... type
styleAfterType: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterType' }],
[/=/, 'delimiter', '@styleAfterTypeEquals'],
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type>
[/[ \t\r\n]+/], // whitespace
[/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
],
// After <style ... type =
styleAfterTypeEquals: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterTypeEquals' }],
[/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
[/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type=>
[/[ \t\r\n]+/], // whitespace
[/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
],
// After <style ... type = $S2
styleWithCustomType: [
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleWithCustomType.$S2' }],
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }],
[/"([^"]*)"/, 'attribute.value'],
[/'([^']*)'/, 'attribute.value'],
[/[\w\-]+/, 'attribute.name'],
[/=/, 'delimiter'],
[/[ \t\r\n]+/], // whitespace
[/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
],
styleEmbedded: [
[
/\{\{/,
{
token: '@rematch',
switchTo: '@handlebarsInEmbeddedState.styleEmbedded.$S2',
nextEmbedded: '@pop',
},
],
[/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
],
// -- END <style> tags handling
handlebarsInSimpleState: [
[/\{\{\{?/, 'delimiter.handlebars'],
[/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3' }],
{ include: 'handlebarsRoot' },
],
handlebarsInEmbeddedState: [
[/\{\{\{?/, 'delimiter.handlebars'],
[/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3', nextEmbedded: '$S3' }],
{ include: 'handlebarsRoot' },
],
handlebarsRoot: [
[/"[^"]*"/, 'string.handlebars'],
[/[#/][^\s}]+/, 'keyword.helper.handlebars'],
[/else\b/, 'keyword.helper.handlebars'],
[/[\s]+/],
[/[^}]/, 'variable.parameter.handlebars'],
],
},
};
export default {
id: 'vue',
extensions: ['.vue'],
aliases: ['Vue', 'vue'],
mimetypes: ['text/x-vue-template'],
conf,
language,
};
......@@ -68,3 +68,13 @@ export const createPathWithExt = p => {
return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`;
};
export function registerLanguages(def, ...defs) {
if (defs.length) defs.forEach(lang => registerLanguages(lang));
const languageId = def.id;
languages.register(def);
languages.setMonarchTokensProvider(languageId, def.language);
languages.setLanguageConfiguration(languageId, def.conf);
}
---
title: 'Web IDE: Introduce syntax highlighting for .vue files.'
merge_request: 30986
author:
type: added
import { editor as monacoEditor, Uri } from 'monaco-editor';
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import Editor from '~/editor/editor_lite';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
......@@ -41,13 +41,13 @@ describe('Base editor', () => {
let dispose;
beforeEach(() => {
setModel = jasmine.createSpy();
dispose = jasmine.createSpy();
modelSpy = spyOn(monacoEditor, 'createModel').and.returnValue(fakeModel);
instanceSpy = spyOn(monacoEditor, 'create').and.returnValue({
setModel = jest.fn();
dispose = jest.fn();
modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => fakeModel);
instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({
setModel,
dispose,
});
}));
});
it('does nothing if no dom element is supplied', () => {
......@@ -73,7 +73,7 @@ describe('Base editor', () => {
editor.createInstance({ el: editorEl });
expect(editor.editorEl).not.toBe(null);
expect(instanceSpy).toHaveBeenCalledWith(editorEl, jasmine.anything());
expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything());
});
});
......@@ -91,6 +91,11 @@ describe('Base editor', () => {
});
it('is capable of changing the language of the model', () => {
// ignore warnings and errors Monaco posts during setup
// (due to being called from Jest/Node.js environment)
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
const blobRenamedPath = 'test.js';
expect(editor.model.getLanguageIdentifier().language).toEqual('markdown');
......@@ -101,7 +106,7 @@ describe('Base editor', () => {
it('falls back to plaintext if there is no language associated with an extension', () => {
const blobRenamedPath = 'test.myext';
const spy = spyOn(console, 'error');
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
editor.updateModelLanguage(blobRenamedPath);
......@@ -110,14 +115,26 @@ describe('Base editor', () => {
});
});
describe('languages', () => {
it('registers custom languages defined with Monaco', () => {
expect(monacoLanguages.getLanguages()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'vue',
}),
]),
);
});
});
describe('syntax highlighting theme', () => {
let themeDefineSpy;
let themeSetSpy;
let defaultScheme;
beforeEach(() => {
themeDefineSpy = spyOn(monacoEditor, 'defineTheme');
themeSetSpy = spyOn(monacoEditor, 'setTheme');
themeDefineSpy = jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(() => {});
themeSetSpy = jest.spyOn(monacoEditor, 'setTheme').mockImplementation(() => {});
defaultScheme = window.gon.user_color_scheme;
});
......
import { editor as monacoEditor } from 'monaco-editor';
import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
import Editor from '~/ide/lib/editor';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
import { file } from '../helpers';
......@@ -181,6 +181,18 @@ describe('Multi-file editor library', () => {
});
});
describe('languages', () => {
it('registers custom languages defined with Monaco', () => {
expect(monacoLanguages.getLanguages()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'vue',
}),
]),
);
});
});
describe('dispose', () => {
it('calls disposble dispose method', () => {
jest.spyOn(instance.disposable, 'dispose');
......
import { editor } from 'monaco-editor';
import { registerLanguages } from '~/ide/utils';
import vue from '~/ide/lib/languages/vue';
// This file only tests syntax specific to vue. This does not test existing syntaxes
// of html, javascript, css and handlebars, which vue files extend.
describe('tokenization for .vue files', () => {
beforeEach(() => {
registerLanguages(vue);
});
test.each([
[
'<div v-if="something">content</div>',
[
[
{ language: 'vue', offset: 0, type: 'delimiter.html' },
{ language: 'vue', offset: 1, type: 'tag.html' },
{ language: 'vue', offset: 4, type: '' },
{ language: 'vue', offset: 5, type: 'variable' },
{ language: 'vue', offset: 21, type: 'delimiter.html' },
{ language: 'vue', offset: 22, type: '' },
{ language: 'vue', offset: 29, type: 'delimiter.html' },
{ language: 'vue', offset: 31, type: 'tag.html' },
{ language: 'vue', offset: 34, type: 'delimiter.html' },
],
],
],
[
'<input :placeholder="placeholder">',
[
[
{ language: 'vue', offset: 0, type: 'delimiter.html' },
{ language: 'vue', offset: 1, type: 'tag.html' },
{ language: 'vue', offset: 6, type: '' },
{ language: 'vue', offset: 7, type: 'variable' },
{ language: 'vue', offset: 33, type: 'delimiter.html' },
],
],
],
[
'<gl-modal @ok="submitForm()"></gl-modal>',
[
[
{ language: 'vue', offset: 0, type: 'delimiter.html' },
{ language: 'vue', offset: 1, type: 'tag.html' },
{ language: 'vue', offset: 3, type: 'attribute.name' },
{ language: 'vue', offset: 9, type: '' },
{ language: 'vue', offset: 10, type: 'variable' },
{ language: 'vue', offset: 28, type: 'delimiter.html' },
{ language: 'vue', offset: 31, type: 'tag.html' },
{ language: 'vue', offset: 33, type: 'attribute.name' },
{ language: 'vue', offset: 39, type: 'delimiter.html' },
],
],
],
[
'<a v-on:click.stop="doSomething">...</a>',
[
[
{ language: 'vue', offset: 0, type: 'delimiter.html' },
{ language: 'vue', offset: 1, type: 'tag.html' },
{ language: 'vue', offset: 2, type: '' },
{ language: 'vue', offset: 3, type: 'variable' },
{ language: 'vue', offset: 32, type: 'delimiter.html' },
{ language: 'vue', offset: 33, type: '' },
{ language: 'vue', offset: 36, type: 'delimiter.html' },
{ language: 'vue', offset: 38, type: 'tag.html' },
{ language: 'vue', offset: 39, type: 'delimiter.html' },
],
],
],
[
'<a @[event]="doSomething">...</a>',
[
[
{ language: 'vue', offset: 0, type: 'delimiter.html' },
{ language: 'vue', offset: 1, type: 'tag.html' },
{ language: 'vue', offset: 2, type: '' },
{ language: 'vue', offset: 3, type: 'variable' },
{ language: 'vue', offset: 25, type: 'delimiter.html' },
{ language: 'vue', offset: 26, type: '' },
{ language: 'vue', offset: 29, type: 'delimiter.html' },
{ language: 'vue', offset: 31, type: 'tag.html' },
{ language: 'vue', offset: 32, type: 'delimiter.html' },
],
],
],
])('%s', (string, tokens) => {
expect(editor.tokenize(string, 'vue')).toEqual(tokens);
});
});
import { commitItemIconMap } from '~/ide/constants';
import { getCommitIconMap, isTextFile } from '~/ide/utils';
import { getCommitIconMap, isTextFile, registerLanguages } from '~/ide/utils';
import { decorateData } from '~/ide/stores/utils';
import { languages } from 'monaco-editor';
describe('WebIDE utils', () => {
describe('isTextFile', () => {
......@@ -102,4 +103,78 @@ describe('WebIDE utils', () => {
expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified);
});
});
describe('registerLanguages', () => {
let langs;
beforeEach(() => {
langs = [
{
id: 'html',
extensions: ['.html'],
conf: { comments: { blockComment: ['<!--', '-->'] } },
language: { tokenizer: {} },
},
{
id: 'css',
extensions: ['.css'],
conf: { comments: { blockComment: ['/*', '*/'] } },
language: { tokenizer: {} },
},
{
id: 'js',
extensions: ['.js'],
conf: { comments: { blockComment: ['/*', '*/'] } },
language: { tokenizer: {} },
},
];
jest.spyOn(languages, 'register').mockImplementation(() => {});
jest.spyOn(languages, 'setMonarchTokensProvider').mockImplementation(() => {});
jest.spyOn(languages, 'setLanguageConfiguration').mockImplementation(() => {});
});
it('registers all the passed languages with Monaco', () => {
registerLanguages(...langs);
expect(languages.register.mock.calls).toEqual([
[
{
conf: { comments: { blockComment: ['/*', '*/'] } },
extensions: ['.css'],
id: 'css',
language: { tokenizer: {} },
},
],
[
{
conf: { comments: { blockComment: ['/*', '*/'] } },
extensions: ['.js'],
id: 'js',
language: { tokenizer: {} },
},
],
[
{
conf: { comments: { blockComment: ['<!--', '-->'] } },
extensions: ['.html'],
id: 'html',
language: { tokenizer: {} },
},
],
]);
expect(languages.setMonarchTokensProvider.mock.calls).toEqual([
['css', { tokenizer: {} }],
['js', { tokenizer: {} }],
['html', { tokenizer: {} }],
]);
expect(languages.setLanguageConfiguration.mock.calls).toEqual([
['css', { comments: { blockComment: ['/*', '*/'] } }],
['js', { comments: { blockComment: ['/*', '*/'] } }],
['html', { comments: { blockComment: ['<!--', '-->'] } }],
]);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment