Commit b40cd08a authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Kushal Pandya

Improve the performance of highlight.js

Improved the performance by highlighting in chunks
parent f0c53b2b
......@@ -37,6 +37,7 @@ const LineHighlighter = function (options = {}) {
options.fileHolderSelector = options.fileHolderSelector || '.file-holder';
options.scrollFileHolder = options.scrollFileHolder || false;
options.hash = options.hash || window.location.hash;
options.scrollBehavior = options.scrollBehavior || 'smooth';
this.options = options;
this._hash = options.hash;
......@@ -74,6 +75,7 @@ LineHighlighter.prototype.highlightHash = function (newHash) {
// Scroll to the first highlighted line on initial load
// Add an offset of -100 for some context
offset: -100,
behavior: this.options.scrollBehavior,
});
}
}
......
<script>
import { GlIcon, GlLink } from '@gitlab/ui';
export default {
components: {
GlIcon,
GlLink,
},
props: {
lines: {
type: Number,
required: true,
},
},
};
</script>
<template>
<div class="line-numbers">
<gl-link
v-for="line in lines"
:id="`L${line}`"
:key="line"
class="diff-line-num gl-shadow-none!"
:to="`#LC${line}`"
:data-line-number="line"
>
<gl-icon :size="12" name="link" />
{{ line }}
</gl-link>
</div>
</template>
<script>
import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui';
import ChunkLine from './chunk_line.vue';
/*
* We only highlight the chunk that is currently visible to the user.
* By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
*
* Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
* so by making text transparent and rendering raw (non-highlighted) text,
* the browser spends less resources on painting content that is not immediately relevant.
*
* Why use transparent text as opposed to hiding content entirely?
* 1. If content is hidden entirely, native find text (⌘ + F) won't work.
* 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
*/
export default {
components: {
ChunkLine,
GlIntersectionObserver,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: {
chunkIndex: {
type: Number,
required: false,
default: 0,
},
isHighlighted: {
type: Boolean,
required: true,
},
content: {
type: String,
required: true,
},
startingFrom: {
type: Number,
required: false,
default: 0,
},
totalLines: {
type: Number,
required: false,
default: 0,
},
language: {
type: String,
required: false,
default: null,
},
},
computed: {
lines() {
return this.content.split('\n');
},
},
methods: {
handleChunkAppear() {
if (!this.isHighlighted) {
this.$emit('appear', this.chunkIndex);
}
},
},
};
</script>
<template>
<div>
<gl-intersection-observer @appear="handleChunkAppear">
<div v-if="isHighlighted">
<chunk-line
v-for="(line, index) in lines"
:key="index"
:number="startingFrom + index + 1"
:content="line"
:language="language"
/>
</div>
<div v-else class="gl-display-flex">
<div class="gl-display-flex gl-flex-direction-column">
<a
v-for="(n, index) in totalLines"
:id="`L${startingFrom + index + 1}`"
:key="index"
class="gl-ml-5 gl-text-transparent"
:href="`#L${startingFrom + index + 1}`"
:data-line-number="startingFrom + index + 1"
data-testid="line-number"
>
{{ startingFrom + index + 1 }}
</a>
</div>
<div
v-safe-html="content"
class="gl-white-space-pre-wrap! gl-text-transparent"
data-testid="content"
></div>
</div>
</gl-intersection-observer>
</div>
</template>
<script>
import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
export default {
components: {
GlLink,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: {
number: {
type: Number,
required: true,
},
content: {
type: String,
required: true,
},
language: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="gl-display-flex">
<div class="line-numbers gl-pt-0! gl-pb-0! gl-absolute gl-z-index-3">
<gl-link
:id="`L${number}`"
class="file-line-num diff-line-num gl-user-select-none"
:to="`#L${number}`"
:data-line-number="number"
>
{{ number }}
</gl-link>
</div>
<pre
class="code highlight gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11!"
><code><span :id="`LC${number}`" v-safe-html="content" :lang="language" class="line" data-testid="content"></span></code></pre>
</div>
</template>
......@@ -109,3 +109,5 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
xquery: 'xquery',
yaml: 'yaml',
};
export const LINES_PER_CHUNK = 70;
<script>
import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import { sanitize } from '~/lib/dompurify';
import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants';
import { wrapLines } from './utils';
const LINE_SELECT_CLASS_NAME = 'hll';
import LineHighlighter from '~/blob/line_highlighter';
import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
import Chunk from './components/chunk.vue';
/*
* This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
* we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
*
* The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
* Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
* it does not trigger a repaint on a parent element that wraps all 1000 lines.
*/
export default {
components: {
LineNumbers,
GlLoadingIcon,
Chunk,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
......@@ -27,46 +32,92 @@ export default {
content: this.blob.rawTextBlob,
language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
hljs: null,
firstChunk: null,
chunks: {},
isLoading: true,
isLineSelected: false,
lineHighlighter: null,
};
},
computed: {
splitContent() {
return this.content.split('\n');
},
lineNumbers() {
return this.content.split('\n').length;
return this.splitContent.length;
},
highlightedContent() {
let highlightedContent;
let { language } = this;
if (this.hljs) {
if (!language) {
const hljsHighlightAuto = this.hljs.highlightAuto(this.content);
},
async created() {
this.generateFirstChunk();
this.hljs = await this.loadHighlightJS();
highlightedContent = hljsHighlightAuto.value;
language = hljsHighlightAuto.language;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
}
if (this.language) {
this.languageDefinition = await this.loadLanguage();
}
return wrapLines(highlightedContent, language);
// Highlight the first chunk as soon as highlight.js is available
this.highlightChunk(null, true);
window.requestIdleCallback(async () => {
// Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
this.generateRemainingChunks();
this.isLoading = false;
await this.$nextTick();
this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
});
},
methods: {
generateFirstChunk() {
const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
this.firstChunk = this.createChunk(lines);
},
generateRemainingChunks() {
const result = {};
for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
}
this.chunks = result;
},
watch: {
highlightedContent() {
this.$nextTick(() => this.selectLine());
createChunk(lines, startingFrom = 0) {
return {
content: lines.join('\n'),
startingFrom,
totalLines: lines.length,
language: this.language,
isHighlighted: false,
};
},
$route() {
highlightChunk(index, isFirstChunk) {
const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
if (chunk.isHighlighted) {
return;
}
const { highlightedContent, language } = this.highlight(chunk.content, this.language);
Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
this.selectLine();
},
},
async mounted() {
this.hljs = await this.loadHighlightJS();
if (this.language) {
this.languageDefinition = await this.loadLanguage();
highlight(content, language) {
let detectedLanguage = language;
let highlightedContent;
if (this.hljs) {
if (!detectedLanguage) {
const hljsHighlightAuto = this.hljs.highlightAuto(content);
highlightedContent = hljsHighlightAuto.value;
detectedLanguage = hljsHighlightAuto.language;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
}
}
return { highlightedContent, language: detectedLanguage };
},
methods: {
loadHighlightJS() {
// If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
......@@ -83,21 +134,14 @@ export default {
return languageDefinition;
},
selectLine() {
const hash = sanitize(this.$route.hash);
const lineToSelect = hash && this.$el.querySelector(hash);
if (!lineToSelect) {
async selectLine() {
if (this.isLineSelected || !this.lineHighlighter) {
return;
}
if (this.$options.currentlySelectedLine) {
this.$options.currentlySelectedLine.classList.remove(LINE_SELECT_CLASS_NAME);
}
lineToSelect.classList.add(LINE_SELECT_CLASS_NAME);
this.$options.currentlySelectedLine = lineToSelect;
lineToSelect.scrollIntoView({ behavior: 'smooth', block: 'center' });
this.isLineSelected = true;
await this.$nextTick();
this.lineHighlighter.highlightHash(this.$route.hash);
},
},
userColorScheme: window.gon.user_color_scheme,
......@@ -105,16 +149,35 @@ export default {
};
</script>
<template>
<gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" />
<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 gl-flex-direction-column gl-overflow-auto"
:class="$options.userColorScheme"
data-type="simple"
data-qa-selector="blob_viewer_file_content"
>
<line-numbers :lines="lineNumbers" />
<pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code>
</pre>
<chunk
v-if="firstChunk"
:lines="firstChunk.lines"
:total-lines="firstChunk.totalLines"
:content="firstChunk.content"
:starting-from="firstChunk.startingFrom"
:is-highlighted="firstChunk.isHighlighted"
:language="firstChunk.language"
/>
<gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
<chunk
v-for="(chunk, key, index) in chunks"
v-else
:key="key"
:lines="chunk.lines"
:content="chunk.content"
:total-lines="chunk.totalLines"
:starting-from="chunk.startingFrom"
:is-highlighted="chunk.isHighlighted"
:chunk-index="index"
:language="chunk.language"
@appear="highlightChunk"
/>
</div>
</template>
export const wrapLines = (content, language) => {
const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
return (
content &&
content
.split('\n')
.map((line, i) => {
let formattedLine;
const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`;
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)/, `${attributes} `);
} else {
formattedLine = `<span ${attributes} class="line">${line}</span>`;
}
return formattedLine;
})
.join('\n')
);
};
......@@ -39,7 +39,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5")))
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
......@@ -50,7 +50,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5")))
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
end
......@@ -75,7 +75,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5")))
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
......@@ -86,7 +86,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5")))
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
end
end
......
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlLink } from '@gitlab/ui';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
describe('Line Numbers component', () => {
let wrapper;
const lines = 10;
const createComponent = () => {
wrapper = shallowMount(LineNumbers, { propsData: { lines } });
};
const findGlIcon = () => wrapper.findComponent(GlIcon);
const findLineNumbers = () => wrapper.findAllComponents(GlLink);
const findFirstLineNumber = () => findLineNumbers().at(0);
beforeEach(() => createComponent());
afterEach(() => wrapper.destroy());
describe('rendering', () => {
it('renders Line Numbers', () => {
expect(findLineNumbers().length).toBe(lines);
expect(findFirstLineNumber().attributes()).toMatchObject({
id: 'L1',
to: '#LC1',
});
});
it('renders a link icon', () => {
expect(findGlIcon().props()).toMatchObject({
size: 12,
name: 'link',
});
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
const DEFAULT_PROPS = {
number: 2,
content: '// Line content',
language: 'javascript',
};
describe('Chunk Line component', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props } });
};
const findLink = () => wrapper.findComponent(GlLink);
const findContent = () => wrapper.findByTestId('content');
beforeEach(() => {
createComponent();
});
afterEach(() => wrapper.destroy());
describe('rendering', () => {
it('renders a line number', () => {
expect(findLink().attributes()).toMatchObject({
'data-line-number': `${DEFAULT_PROPS.number}`,
to: `#L${DEFAULT_PROPS.number}`,
id: `L${DEFAULT_PROPS.number}`,
});
expect(findLink().text()).toBe(DEFAULT_PROPS.number.toString());
});
it('renders content', () => {
expect(findContent().attributes()).toMatchObject({
id: `LC${DEFAULT_PROPS.number}`,
lang: DEFAULT_PROPS.language,
});
expect(findContent().text()).toBe(DEFAULT_PROPS.content);
});
});
});
import { GlIntersectionObserver } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
const DEFAULT_PROPS = {
chunkIndex: 2,
isHighlighted: false,
content: '// Line 1 content \n // Line 2 content',
startingFrom: 140,
totalLines: 50,
language: 'javascript',
};
describe('Chunk component', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } });
};
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findChunkLines = () => wrapper.findAllComponents(ChunkLine);
const findLineNumbers = () => wrapper.findAllByTestId('line-number');
const findContent = () => wrapper.findByTestId('content');
beforeEach(() => {
createComponent();
});
afterEach(() => wrapper.destroy());
describe('Intersection observer', () => {
it('renders an Intersection observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
it('emits an appear event when intersection-observer appears', () => {
findIntersectionObserver().vm.$emit('appear');
expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]);
});
it('does not emit an appear event is isHighlighted is true', () => {
createComponent({ isHighlighted: true });
findIntersectionObserver().vm.$emit('appear');
expect(wrapper.emitted('appear')).toEqual(undefined);
});
});
describe('rendering', () => {
it('does not render a Chunk Line component if isHighlighted is false', () => {
expect(findChunkLines().length).toBe(0);
});
it('renders simplified line numbers and content if isHighlighted is false', () => {
expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines);
expect(findLineNumbers().at(0).attributes()).toMatchObject({
'data-line-number': `${DEFAULT_PROPS.startingFrom + 1}`,
href: `#L${DEFAULT_PROPS.startingFrom + 1}`,
id: `L${DEFAULT_PROPS.startingFrom + 1}`,
});
expect(findContent().text()).toBe(DEFAULT_PROPS.content);
});
it('renders Chunk Line components if isHighlighted is true', () => {
const splitContent = DEFAULT_PROPS.content.split('\n');
createComponent({ isHighlighted: true });
expect(findChunkLines().length).toBe(splitContent.length);
expect(findChunkLines().at(0).props()).toMatchObject({
number: DEFAULT_PROPS.startingFrom + 1,
content: splitContent[0],
language: DEFAULT_PROPS.language,
});
});
});
});
import hljs from 'highlight.js/lib/core';
import { GlLoadingIcon } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vue from 'vue';
import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import waitForPromises from 'helpers/wait_for_promises';
import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils';
import LineHighlighter from '~/blob/line_highlighter';
jest.mock('~/blob/line_highlighter');
jest.mock('highlight.js/lib/core');
Vue.use(VueRouter);
const router = new VueRouter();
const generateContent = (content, totalLines = 1) => {
let generatedContent = '';
for (let i = 0; i < totalLines; i += 1) {
generatedContent += `Line: ${i + 1} = ${content}\n`;
}
return generatedContent;
};
const execImmediately = (callback) => callback();
describe('Source Viewer component', () => {
let wrapper;
const language = 'docker';
const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language];
const content = `// Some source code`;
const chunk1 = generateContent('// Some source code 1', 70);
const chunk2 = generateContent('// Some source code 2', 70);
const content = chunk1 + chunk2;
const DEFAULT_BLOB_DATA = { language, rawTextBlob: content };
const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
......@@ -29,15 +41,12 @@ describe('Source Viewer component', () => {
await waitForPromises();
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findLineNumbers = () => wrapper.findComponent(LineNumbers);
const findHighlightedContent = () => wrapper.findByTestId('test-highlighted');
const findFirstLine = () => wrapper.find('#LC1');
const findChunks = () => wrapper.findAllComponents(Chunk);
beforeEach(() => {
hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
jest.spyOn(sourceViewerUtils, 'wrapLines');
jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
return createComponent();
});
......@@ -45,6 +54,8 @@ describe('Source Viewer component', () => {
afterEach(() => wrapper.destroy());
describe('highlight.js', () => {
beforeEach(() => createComponent({ language: mappedLanguage }));
it('registers the language definition', async () => {
const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`);
......@@ -54,72 +65,46 @@ describe('Source Viewer component', () => {
);
});
it('highlights the content', () => {
expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage });
it('highlights the first chunk', () => {
expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage });
});
describe('auto-detects if a language cannot be loaded', () => {
beforeEach(() => createComponent({ language: 'some_unknown_language' }));
it('highlights the content with auto-detection', () => {
expect(hljs.highlightAuto).toHaveBeenCalledWith(content);
expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim());
});
});
});
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('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => {
expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage);
});
it('renders the first chunk', async () => {
const firstChunk = findChunks().at(0);
it('renders Line Numbers', () => {
expect(findLineNumbers().props('lines')).toBe(1);
});
expect(firstChunk.props('content')).toContain(chunk1);
it('renders the highlighted content', () => {
expect(findHighlightedContent().exists()).toBe(true);
expect(firstChunk.props()).toMatchObject({
totalLines: 70,
startingFrom: 0,
});
});
describe('selecting a line', () => {
let firstLine;
let firstLineElement;
it('renders the second chunk', async () => {
const secondChunk = findChunks().at(1);
beforeEach(() => {
firstLine = findFirstLine();
firstLineElement = firstLine.element;
expect(secondChunk.props('content')).toContain(chunk2.trim());
jest.spyOn(firstLineElement, 'scrollIntoView');
jest.spyOn(firstLineElement.classList, 'add');
jest.spyOn(firstLineElement.classList, 'remove');
expect(secondChunk.props()).toMatchObject({
totalLines: 70,
startingFrom: 70,
});
it('adds the highlight (hll) class', async () => {
wrapper.vm.$router.push('#LC1');
await nextTick();
expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll');
});
it('removes the highlight (hll) class from a previously highlighted line', async () => {
wrapper.vm.$router.push('#LC2');
await nextTick();
expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll');
});
it('scrolls the line into view', () => {
expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
describe('LineHighlighter', () => {
it('instantiates the lineHighlighter class', async () => {
expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
});
});
});
import { wrapLines } from '~/vue_shared/components/source_viewer/utils';
describe('Wrap lines', () => {
it.each`
content | language | output
${'line 1'} | ${'javascript'} | ${'<span id="LC1" lang="javascript" class="line">line 1</span>'}
${'line 1\nline 2'} | ${'html'} | ${`<span id="LC1" lang="html" class="line">line 1</span>\n<span id="LC2" lang="html" class="line">line 2</span>`}
${'<span class="hljs-code">line 1\nline 2</span>'} | ${'html'} | ${`<span id="LC1" lang="html" class="hljs-code">line 1\n<span id="LC2" lang="html" class="line">line 2</span></span>`}
${'<span class="hljs-code">```bash'} | ${'bash'} | ${'<span id="LC1" lang="bash" class="hljs-code">```bash'}
${'<span class="hljs-code">```bash'} | ${'valid-language1'} | ${'<span id="LC1" lang="valid-language1" class="hljs-code">```bash'}
${'<span class="hljs-code">```bash'} | ${'valid_language2'} | ${'<span id="LC1" lang="valid_language2" class="hljs-code">```bash'}
`('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => {
expect(wrapLines(content, language)).toBe(output);
});
it.each`
language
${'invalidLanguage>'}
${'"invalidLanguage"'}
${'<invalidLanguage'}
`('returns lines safely without XSS language is not valid', ({ language }) => {
expect(wrapLines('<span class="hljs-code">```bash', language)).toBe(
'<span id="LC1" lang="" class="hljs-code">```bash',
);
});
});
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