Commit e9edcbee authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '339269-load-highlightjs-asynchronously' into 'master'

Load highlight.js languages asynchronously [with fixed bug]

See merge request gitlab-org/gitlab!83193
parents 456365c3 780039e9
......@@ -30,6 +30,7 @@ export default {
>
<div
v-if="isLoading"
data-testid="content-editor-loading-indicator"
class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
>
<div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
......
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { lowlight } from 'lowlight/lib/all';
import { textblockTypeInputRule } from '@tiptap/core';
import { isFunction } from 'lodash';
const extractLanguage = (element) => element.getAttribute('lang');
const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
const loadLanguageFromInputRule = (languageLoader) => (match) => {
const language = match[1];
if (isFunction(languageLoader?.loadLanguages)) {
languageLoader.loadLanguages([language]);
}
return {
language,
};
};
export default CodeBlockLowlight.extend({
isolating: true,
addOptions() {
return {
...this.parent?.(),
languageLoader: {},
};
},
addAttributes() {
return {
language: {
......@@ -18,6 +40,22 @@ export default CodeBlockLowlight.extend({
},
};
},
addInputRules() {
const { languageLoader } = this.options;
return [
textblockTypeInputRule({
find: backtickInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
}),
];
},
renderHTML({ HTMLAttributes }) {
return [
'pre',
......@@ -28,6 +66,4 @@ export default CodeBlockLowlight.extend({
['code', {}, 0],
];
},
}).configure({
lowlight,
});
export default class CodeBlockLanguageLoader {
constructor(lowlight) {
this.lowlight = lowlight;
}
isLanguageLoaded(language) {
return this.lowlight.registered(language);
}
loadLanguagesFromDOM(domTree) {
const languages = [];
domTree.querySelectorAll('pre').forEach((preElement) => {
languages.push(preElement.getAttribute('lang'));
});
return this.loadLanguages(languages);
}
loadLanguages(languageList = []) {
const loaders = languageList
.filter((languageName) => !this.isLanguageLoaded(languageName))
.map((languageName) => {
return import(
/* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}`
)
.then(({ default: language }) => {
this.lowlight.registerLanguage(languageName, language);
})
.catch(() => false);
});
return Promise.all(loaders);
}
}
......@@ -3,11 +3,12 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub;
this._languageLoader = languageLoader;
}
get tiptapEditor() {
......@@ -34,23 +35,33 @@ export class ContentEditor {
}
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
const {
_tiptapEditor: editor,
_deserializer: deserializer,
_eventHub: eventHub,
_languageLoader: languageLoader,
} = this;
const { doc, tr } = editor.state;
const selection = TextSelection.create(doc, 0, doc.content.size);
try {
eventHub.$emit(LOADING_CONTENT_EVENT);
const { document } = await deserializer.deserialize({
const result = await deserializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
if (document) {
if (Object.keys(result).length !== 0) {
const { document, dom } = result;
await languageLoader.loadLanguagesFromDOM(dom);
tr.setSelection(selection)
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
}
eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
eventHub.$emit(LOADING_ERROR_EVENT, e);
......
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
......@@ -58,6 +59,7 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
import CodeBlockLanguageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
......@@ -83,6 +85,7 @@ export const createContentEditor = ({
const eventHub = eventHubFactory();
const languageLoader = new CodeBlockLanguageLoader(lowlight);
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
......@@ -91,7 +94,7 @@ export const createContentEditor = ({
BulletList,
Code,
ColorChip,
CodeBlockHighlight,
CodeBlockHighlight.configure({ lowlight, languageLoader }),
DescriptionItem,
DescriptionList,
Details,
......@@ -105,7 +108,7 @@ export const createContentEditor = ({
FootnoteDefinition,
FootnoteReference,
FootnotesSection,
Frontmatter,
Frontmatter.configure({ lowlight }),
Gapcursor,
HardBreak,
Heading,
......@@ -144,5 +147,5 @@ export const createContentEditor = ({
const serializer = createMarkdownSerializer({ serializerConfig });
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader });
};
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import { createTestEditor } from '../test_utils';
import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils';
const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true">
<code>
......@@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language
describe('content_editor/extensions/code_block_highlight', () => {
let parsedCodeBlockHtmlFixture;
let tiptapEditor;
let doc;
let codeBlock;
let languageLoader;
const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html');
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => {
tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
languageLoader = { loadLanguages: jest.fn() };
tiptapEditor = createTestEditor({
extensions: [CodeBlockHighlight.configure({ languageLoader })],
});
tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
({
builders: { doc, codeBlock },
} = createDocBuilder({
tiptapEditor,
names: {
codeBlock: { nodeType: CodeBlockHighlight.name },
},
}));
});
it('extracts language and params attributes from Markdown API output', () => {
const language = preElement().getAttribute('lang');
describe('when parsing HTML', () => {
beforeEach(() => {
parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
});
it('extracts language and params attributes from Markdown API output', () => {
const language = preElement().getAttribute('lang');
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
});
});
it('adds code, highlight, and js-syntax-highlight to code block element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight');
});
});
it('adds code, highlight, and js-syntax-highlight to code block element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
it('adds content-editor-code-block class to the pre element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight');
expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
});
});
it('adds content-editor-code-block class to the pre element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
describe.each`
inputRule
${'```'}
${'~~~'}
`('when typing $inputRule input rule', ({ inputRule }) => {
const language = 'javascript';
beforeEach(() => {
triggerNodeInputRule({
tiptapEditor,
inputRuleText: `${inputRule}${language} `,
});
});
it('creates a new code block and loads related language', () => {
const expectedDoc = doc(codeBlock({ language }));
expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
it('loads language when language loader is available', () => {
expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]);
});
});
});
import CodeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader';
describe('content_editor/services/code_block_language_loader', () => {
let languageLoader;
let lowlight;
beforeEach(() => {
lowlight = {
languages: [],
registerLanguage: jest
.fn()
.mockImplementation((language) => lowlight.languages.push(language)),
registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)),
};
languageLoader = new CodeBlockLanguageBlocker(lowlight);
});
describe('loadLanguages', () => {
it('loads highlight.js language packages identified by a list of languages', async () => {
const languages = ['javascript', 'ruby'];
await languageLoader.loadLanguages(languages);
languages.forEach((language) => {
expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function));
});
});
describe('when language is already registered', () => {
it('does not load the language again', async () => {
const languages = ['javascript'];
await languageLoader.loadLanguages(languages);
await languageLoader.loadLanguages(languages);
expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1);
});
});
});
describe('loadLanguagesFromDOM', () => {
it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => {
const parser = new DOMParser();
const { body } = parser.parseFromString(
`
<pre lang="javascript"></pre>
<pre lang="ruby"></pre>
`,
'text/html',
);
await languageLoader.loadLanguagesFromDOM(body);
expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function));
});
});
describe('isLanguageLoaded', () => {
it('returns true when a language is registered', async () => {
const language = 'javascript';
expect(languageLoader.isLanguageLoaded(language)).toBe(false);
await languageLoader.loadLanguages([language]);
expect(languageLoader.isLanguageLoaded(language)).toBe(true);
});
});
});
......@@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
let deserializer;
let languageLoader;
let eventHub;
let doc;
let p;
......@@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => {
serializer = { deserialize: jest.fn() };
deserializer = { deserialize: jest.fn() };
languageLoader = { loadLanguagesFromDOM: jest.fn() };
eventHub = eventHubFactory();
contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub });
contentEditor = new ContentEditor({
tiptapEditor,
serializer,
deserializer,
eventHub,
languageLoader,
});
});
describe('.dispose', () => {
......@@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => {
describe('when setSerializedContent succeeds', () => {
let document;
const dom = {};
const testMarkdown = '**bold text**';
beforeEach(() => {
document = doc(p('document'));
deserializer.deserialize.mockResolvedValueOnce({ document });
deserializer.deserialize.mockResolvedValueOnce({ document, dom });
});
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
......@@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => {
expect(loadingContentEmitted).toBe(true);
});
contentEditor.setSerializedContent('**bold text**');
contentEditor.setSerializedContent(testMarkdown);
});
it('sets the deserialized document in the tiptap editor object', async () => {
await contentEditor.setSerializedContent('**bold text**');
await contentEditor.setSerializedContent(testMarkdown);
expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
});
it('passes deserialized DOM document to language loader', async () => {
await contentEditor.setSerializedContent(testMarkdown);
expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom);
});
});
describe('when setSerializedContent fails', () => {
......
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { ContentEditor } from '~/content_editor';
/**
* This spec exercises some workflows in the Content Editor without mocking
* any component.
*
*/
describe('content_editor', () => {
let wrapper;
let renderMarkdown;
let contentEditorService;
const buildWrapper = () => {
renderMarkdown = jest.fn();
wrapper = mountExtended(ContentEditor, {
propsData: {
renderMarkdown,
uploadsPath: '/',
},
listeners: {
initialized(contentEditor) {
contentEditorService = contentEditor;
},
},
});
};
describe('when loading initial content', () => {
describe('when the initial content is empty', () => {
it('still hides the loading indicator', async () => {
buildWrapper();
renderMarkdown.mockResolvedValue('');
await contentEditorService.setSerializedContent('');
await nextTick();
expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
});
});
describe('when the initial content is not empty', () => {
const initialContent = '<p><strong>bold text</strong></p>';
beforeEach(async () => {
buildWrapper();
renderMarkdown.mockResolvedValue(initialContent);
await contentEditorService.setSerializedContent('**bold text**');
await nextTick();
});
it('hides the loading indicator', async () => {
expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
});
it('displays the initial content', async () => {
expect(wrapper.html()).toContain(initialContent);
});
});
});
});
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