Commit ed8b4479 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch '348145-highlightjs-language' into 'master'

Pass language to the SourceViewer component

See merge request gitlab-org/gitlab!79412
parents d87e3b00 5da70294
...@@ -3,7 +3,7 @@ const viewers = { ...@@ -3,7 +3,7 @@ const viewers = {
image: () => import('./image_viewer.vue'), image: () => import('./image_viewer.vue'),
video: () => import('./video_viewer.vue'), video: () => import('./video_viewer.vue'),
empty: () => import('./empty_viewer.vue'), empty: () => import('./empty_viewer.vue'),
text: () => import('~/vue_shared/components/source_viewer.vue'), text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'),
pdf: () => import('./pdf_viewer.vue'), pdf: () => import('./pdf_viewer.vue'),
lfs: () => import('./lfs_viewer.vue'), lfs: () => import('./lfs_viewer.vue'),
}; };
......
...@@ -20,6 +20,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { ...@@ -20,6 +20,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
rawSize rawSize
rawTextBlob rawTextBlob
fileType fileType
language
path path
editBlobPath editBlobPath
ideEditPath ideEditPath
......
// Language map from Rouge::Lexer to highlight.js
// Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md).
// Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages).
export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
bsl: '1c',
actionscript: 'actionscript',
ada: 'ada',
apache: 'apache',
applescript: 'applescript',
armasm: 'armasm',
awk: 'awk',
c: 'c',
ceylon: 'ceylon',
clean: 'clean',
clojure: 'clojure',
cmake: 'cmake',
coffeescript: 'coffeescript',
coq: 'coq',
cpp: 'cpp',
crystal: 'crystal',
csharp: 'csharp',
css: 'css',
d: 'd',
dart: 'dart',
pascal: 'delphi',
diff: 'diff',
jinja: 'django',
docker: 'dockerfile',
batchfile: 'dos',
elixir: 'elixir',
elm: 'elm',
erb: 'erb',
erlang: 'erlang',
fortran: 'fortran',
fsharp: 'fsharp',
gherkin: 'gherkin',
glsl: 'glsl',
go: 'go',
gradle: 'gradle',
groovy: 'groovy',
haml: 'haml',
handlebars: 'handlebars',
haskell: 'haskell',
haxe: 'haxe',
http: 'http',
hylang: 'hy',
ini: 'ini',
isbl: 'isbl',
java: 'java',
javascript: 'javascript',
json: 'json',
julia: 'julia',
kotlin: 'kotlin',
lasso: 'lasso',
tex: 'latex',
common_lisp: 'lisp',
livescript: 'livescript',
llvm: 'llvm',
hlsl: 'lsl',
lua: 'lua',
make: 'makefile',
markdown: 'markdown',
mathematica: 'mathematica',
matlab: 'matlab',
moonscript: 'moonscript',
nginx: 'nginx',
nim: 'nim',
nix: 'nix',
objective_c: 'objectivec',
ocaml: 'ocaml',
perl: 'perl',
php: 'php',
plaintext: 'plaintext',
pony: 'pony',
powershell: 'powershell',
prolog: 'prolog',
properties: 'properties',
protobuf: 'protobuf',
puppet: 'puppet',
python: 'python',
q: 'q',
qml: 'qml',
r: 'r',
reasonml: 'reasonml',
ruby: 'ruby',
rust: 'rust',
sas: 'sas',
scala: 'scala',
scheme: 'scheme',
scss: 'scss',
shell: 'shell',
smalltalk: 'smalltalk',
sml: 'sml',
sqf: 'sqf',
sql: 'sql',
stan: 'stan',
stata: 'stata',
swift: 'swift',
tap: 'tap',
tcl: 'tcl',
twig: 'twig',
typescript: 'typescript',
vala: 'vala',
vb: 'vbnet',
verilog: 'verilog',
vhdl: 'vhdl',
viml: 'vim',
xml: 'xml',
xquery: 'xquery',
yaml: 'yaml',
};
<script> <script>
import { GlSafeHtmlDirective } from '@gitlab/ui'; import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import { sanitize } from '~/lib/dompurify'; import { sanitize } from '~/lib/dompurify';
import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants';
import { wrapLines } from './utils';
const LINE_SELECT_CLASS_NAME = 'hll'; const LINE_SELECT_CLASS_NAME = 'hll';
const PLAIN_TEXT_LANGUAGE = 'plaintext';
export default { export default {
components: { components: {
LineNumbers, LineNumbers,
GlLoadingIcon,
}, },
directives: { directives: {
SafeHtml: GlSafeHtmlDirective, SafeHtml: GlSafeHtmlDirective,
...@@ -18,17 +20,12 @@ export default { ...@@ -18,17 +20,12 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
autoDetect: {
type: Boolean,
required: false,
default: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145)
},
}, },
data() { data() {
return { return {
languageDefinition: null, languageDefinition: null,
content: this.blob.rawTextBlob, content: this.blob.rawTextBlob,
language: this.blob.language || PLAIN_TEXT_LANGUAGE, language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
hljs: null, hljs: null,
}; };
}, },
...@@ -40,14 +37,14 @@ export default { ...@@ -40,14 +37,14 @@ export default {
let highlightedContent; let highlightedContent;
if (this.hljs) { if (this.hljs) {
if (this.autoDetect) { if (!this.language) {
highlightedContent = this.hljs.highlightAuto(this.content).value; highlightedContent = this.hljs.highlightAuto(this.content).value;
} else if (this.languageDefinition) { } else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value; highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
} }
} }
return this.wrapLines(highlightedContent); return wrapLines(highlightedContent);
}, },
}, },
watch: { watch: {
...@@ -61,14 +58,14 @@ export default { ...@@ -61,14 +58,14 @@ export default {
async mounted() { async mounted() {
this.hljs = await this.loadHighlightJS(); this.hljs = await this.loadHighlightJS();
if (!this.autoDetect) { if (this.language) {
this.languageDefinition = await this.loadLanguage(); this.languageDefinition = await this.loadLanguage();
} }
}, },
methods: { methods: {
loadHighlightJS() { loadHighlightJS() {
// With auto-detect enabled we load all common languages else we load only the core (smallest footprint) // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
}, },
async loadLanguage() { async loadLanguage() {
let languageDefinition; let languageDefinition;
...@@ -82,15 +79,6 @@ export default { ...@@ -82,15 +79,6 @@ export default {
return languageDefinition; return languageDefinition;
}, },
wrapLines(content) {
return (
content &&
content
.split('\n')
.map((line, i) => `<span id="LC${i + 1}" class="line">${line}</span>`)
.join('\r\n')
);
},
selectLine() { selectLine() {
const hash = sanitize(this.$route.hash); const hash = sanitize(this.$route.hash);
const lineToSelect = hash && this.$el.querySelector(hash); const lineToSelect = hash && this.$el.querySelector(hash);
...@@ -113,7 +101,9 @@ export default { ...@@ -113,7 +101,9 @@ export default {
}; };
</script> </script>
<template> <template>
<gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" />
<div <div
v-else
class="file-content code js-syntax-highlight blob-content gl-display-flex" class="file-content code js-syntax-highlight blob-content gl-display-flex"
:class="$options.userColorScheme" :class="$options.userColorScheme"
data-type="simple" data-type="simple"
......
export const wrapLines = (content) => {
return (
content &&
content
.split('\n')
.map((line, i) => {
let formattedLine;
const idAttribute = `id="LC${i + 1}"`;
if (line.includes('<span class="hljs') && !line.includes('</span>')) {
/**
* In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span
*
* example (before): <span class="hljs-code">```bash
* example (after): <span id="LC67" class="hljs-code">```bash
*/
formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `);
} else {
formattedLine = `<span ${idAttribute} class="line">${line}</span>`;
}
return formattedLine;
})
.join('\n')
);
};
...@@ -22,7 +22,7 @@ module QA ...@@ -22,7 +22,7 @@ module QA
element :copy_contents_button element :copy_contents_button
end end
base.view 'app/assets/javascripts/vue_shared/components/source_viewer.vue' do base.view 'app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue' do
element :blob_viewer_file_content element :blob_viewer_file_content
end end
......
...@@ -15,7 +15,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; ...@@ -15,7 +15,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue';
import { loadViewer } from '~/repository/components/blob_viewers'; import { loadViewer } from '~/repository/components/blob_viewers';
import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue';
import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue';
import SourceViewer from '~/vue_shared/components/source_viewer.vue'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
import blobInfoQuery from '~/repository/queries/blob_info.query.graphql'; import blobInfoQuery from '~/repository/queries/blob_info.query.graphql';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { isLoggedIn } from '~/lib/utils/common_utils'; import { isLoggedIn } from '~/lib/utils/common_utils';
......
...@@ -5,6 +5,7 @@ export const simpleViewerMock = { ...@@ -5,6 +5,7 @@ export const simpleViewerMock = {
rawSize: 123, rawSize: 123,
rawTextBlob: 'raw content', rawTextBlob: 'raw content',
fileType: 'text', fileType: 'text',
language: 'javascript',
path: 'some_file.js', path: 'some_file.js',
webPath: 'some_file.js', webPath: 'some_file.js',
editBlobPath: 'some_file.js/edit', editBlobPath: 'some_file.js/edit',
......
import hljs from 'highlight.js/lib/core'; import hljs from 'highlight.js/lib/core';
import { GlLoadingIcon } from '@gitlab/ui';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SourceViewer from '~/vue_shared/components/source_viewer.vue'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants';
import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -12,43 +14,50 @@ const router = new VueRouter(); ...@@ -12,43 +14,50 @@ const router = new VueRouter();
describe('Source Viewer component', () => { describe('Source Viewer component', () => {
let wrapper; let wrapper;
const language = 'javascript'; const language = 'docker';
const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language];
const content = `// Some source code`; const content = `// Some source code`;
const DEFAULT_BLOB_DATA = { language, rawTextBlob: content }; const DEFAULT_BLOB_DATA = { language, rawTextBlob: content };
const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); const createComponent = async (blob = {}) => {
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
const createComponent = async (props = { autoDetect: false }) => {
wrapper = shallowMountExtended(SourceViewer, { wrapper = shallowMountExtended(SourceViewer, {
router, router,
propsData: { blob: { ...DEFAULT_BLOB_DATA }, ...props }, propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } },
}); });
await waitForPromises(); await waitForPromises();
}; };
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findLineNumbers = () => wrapper.findComponent(LineNumbers); const findLineNumbers = () => wrapper.findComponent(LineNumbers);
const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); const findHighlightedContent = () => wrapper.findByTestId('test-highlighted');
const findFirstLine = () => wrapper.find('#LC1'); const findFirstLine = () => wrapper.find('#LC1');
beforeEach(() => createComponent()); beforeEach(() => {
hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
return createComponent();
});
afterEach(() => wrapper.destroy()); afterEach(() => wrapper.destroy());
describe('highlight.js', () => { describe('highlight.js', () => {
it('registers the language definition', async () => { it('registers the language definition', async () => {
const languageDefinition = await import(`highlight.js/lib/languages/${language}`); const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`);
expect(hljs.registerLanguage).toHaveBeenCalledWith(language, languageDefinition.default); expect(hljs.registerLanguage).toHaveBeenCalledWith(
mappedLanguage,
languageDefinition.default,
);
}); });
it('highlights the content', () => { it('highlights the content', () => {
expect(hljs.highlight).toHaveBeenCalledWith(content, { language }); expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage });
}); });
describe('auto-detect enabled', () => { describe('auto-detects if a language cannot be loaded', () => {
beforeEach(() => createComponent({ autoDetect: true })); beforeEach(() => createComponent({ language: 'some_unknown_language' }));
it('highlights the content with auto-detection', () => { it('highlights the content with auto-detection', () => {
expect(hljs.highlightAuto).toHaveBeenCalledWith(content); expect(hljs.highlightAuto).toHaveBeenCalledWith(content);
...@@ -57,6 +66,13 @@ describe('Source Viewer component', () => { ...@@ -57,6 +66,13 @@ describe('Source Viewer component', () => {
}); });
describe('rendering', () => { describe('rendering', () => {
it('renders a loading icon if no highlighted content is available yet', async () => {
hljs.highlight.mockImplementation(() => ({ value: null }));
await createComponent();
expect(findLoadingIcon().exists()).toBe(true);
});
it('renders Line Numbers', () => { it('renders Line Numbers', () => {
expect(findLineNumbers().props('lines')).toBe(1); expect(findLineNumbers().props('lines')).toBe(1);
}); });
......
import { wrapLines } from '~/vue_shared/components/source_viewer/utils';
describe('Wrap lines', () => {
it.each`
input | output
${'line 1'} | ${'<span id="LC1" class="line">line 1</span>'}
${'line 1\nline 2'} | ${`<span id="LC1" class="line">line 1</span>\n<span id="LC2" class="line">line 2</span>`}
${'<span class="hljs-code">line 1\nline 2</span>'} | ${`<span id="LC1" class="hljs-code">line 1\n<span id="LC2" class="line">line 2</span></span>`}
${'<span class="hljs-code">```bash'} | ${'<span id="LC1" class="hljs-code">```bash'}
`('returns lines wrapped in spans containing line numbers', ({ input, output }) => {
expect(wrapLines(input)).toBe(output);
});
});
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