Commit 70f0c25a authored by Natalia Tepluhina's avatar Natalia Tepluhina

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

Load highlight.js asynchronously in the Content Editor

See merge request gitlab-org/gitlab!82638
parents 9f222d4c 6b881013
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; 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 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({ export default CodeBlockLowlight.extend({
isolating: true, isolating: true,
addOptions() {
return {
...this.parent?.(),
languageLoader: {},
};
},
addAttributes() { addAttributes() {
return { return {
language: { language: {
...@@ -18,6 +40,22 @@ export default CodeBlockLowlight.extend({ ...@@ -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 }) { renderHTML({ HTMLAttributes }) {
return [ return [
'pre', 'pre',
...@@ -28,6 +66,4 @@ export default CodeBlockLowlight.extend({ ...@@ -28,6 +66,4 @@ export default CodeBlockLowlight.extend({
['code', {}, 0], ['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 ...@@ -3,11 +3,12 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro
/* eslint-disable no-underscore-dangle */ /* eslint-disable no-underscore-dangle */
export class ContentEditor { export class ContentEditor {
constructor({ tiptapEditor, serializer, deserializer, eventHub }) { constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) {
this._tiptapEditor = tiptapEditor; this._tiptapEditor = tiptapEditor;
this._serializer = serializer; this._serializer = serializer;
this._deserializer = deserializer; this._deserializer = deserializer;
this._eventHub = eventHub; this._eventHub = eventHub;
this._languageLoader = languageLoader;
} }
get tiptapEditor() { get tiptapEditor() {
...@@ -34,23 +35,35 @@ export class ContentEditor { ...@@ -34,23 +35,35 @@ export class ContentEditor {
} }
async setSerializedContent(serializedContent) { 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 { doc, tr } = editor.state;
const selection = TextSelection.create(doc, 0, doc.content.size); const selection = TextSelection.create(doc, 0, doc.content.size);
try { try {
eventHub.$emit(LOADING_CONTENT_EVENT); eventHub.$emit(LOADING_CONTENT_EVENT);
const { document } = await deserializer.deserialize({ const result = await deserializer.deserialize({
schema: editor.schema, schema: editor.schema,
content: serializedContent, content: serializedContent,
}); });
if (document) { if (Object.keys(result).length === 0) {
tr.setSelection(selection) return;
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
} }
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); eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) { } catch (e) {
eventHub.$emit(LOADING_ERROR_EVENT, e); eventHub.$emit(LOADING_ERROR_EVENT, e);
......
import { Editor } from '@tiptap/vue-2'; import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory'; import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment'; import Attachment from '../extensions/attachment';
...@@ -58,6 +59,7 @@ import { ContentEditor } from './content_editor'; ...@@ -58,6 +59,7 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer'; import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer'; import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
import CodeBlockLanguageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) => const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({ new Editor({
...@@ -83,6 +85,7 @@ export const createContentEditor = ({ ...@@ -83,6 +85,7 @@ export const createContentEditor = ({
const eventHub = eventHubFactory(); const eventHub = eventHubFactory();
const languageLoader = new CodeBlockLanguageLoader(lowlight);
const builtInContentEditorExtensions = [ const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio, Audio,
...@@ -91,7 +94,7 @@ export const createContentEditor = ({ ...@@ -91,7 +94,7 @@ export const createContentEditor = ({
BulletList, BulletList,
Code, Code,
ColorChip, ColorChip,
CodeBlockHighlight, CodeBlockHighlight.configure({ lowlight, languageLoader }),
DescriptionItem, DescriptionItem,
DescriptionList, DescriptionList,
Details, Details,
...@@ -105,7 +108,7 @@ export const createContentEditor = ({ ...@@ -105,7 +108,7 @@ export const createContentEditor = ({
FootnoteDefinition, FootnoteDefinition,
FootnoteReference, FootnoteReference,
FootnotesSection, FootnotesSection,
Frontmatter, Frontmatter.configure({ lowlight }),
Gapcursor, Gapcursor,
HardBreak, HardBreak,
Heading, Heading,
...@@ -144,5 +147,5 @@ export const createContentEditor = ({ ...@@ -144,5 +147,5 @@ export const createContentEditor = ({
const serializer = createMarkdownSerializer({ serializerConfig }); const serializer = createMarkdownSerializer({ serializerConfig });
const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); 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 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"> const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true">
<code> <code>
...@@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language ...@@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language
describe('content_editor/extensions/code_block_highlight', () => { describe('content_editor/extensions/code_block_highlight', () => {
let parsedCodeBlockHtmlFixture; let parsedCodeBlockHtmlFixture;
let tiptapEditor; let tiptapEditor;
let doc;
let codeBlock;
let languageLoader;
const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html');
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => { beforeEach(() => {
tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); languageLoader = { loadLanguages: jest.fn() };
parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); 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', () => { describe('when parsing HTML', () => {
const language = preElement().getAttribute('lang'); beforeEach(() => {
parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
language, });
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', () => { it('adds content-editor-code-block class to the pre element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); 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', () => { describe.each`
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); 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', () => { ...@@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => {
let contentEditor; let contentEditor;
let serializer; let serializer;
let deserializer; let deserializer;
let languageLoader;
let eventHub; let eventHub;
let doc; let doc;
let p; let p;
...@@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => { ...@@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => {
serializer = { deserialize: jest.fn() }; serializer = { deserialize: jest.fn() };
deserializer = { deserialize: jest.fn() }; deserializer = { deserialize: jest.fn() };
languageLoader = { loadLanguagesFromDOM: jest.fn() };
eventHub = eventHubFactory(); eventHub = eventHubFactory();
contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub }); contentEditor = new ContentEditor({
tiptapEditor,
serializer,
deserializer,
eventHub,
languageLoader,
});
}); });
describe('.dispose', () => { describe('.dispose', () => {
...@@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => { ...@@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => {
describe('when setSerializedContent succeeds', () => { describe('when setSerializedContent succeeds', () => {
let document; let document;
const dom = {};
const testMarkdown = '**bold text**';
beforeEach(() => { beforeEach(() => {
document = doc(p('document')); document = doc(p('document'));
deserializer.deserialize.mockResolvedValueOnce({ document }); deserializer.deserialize.mockResolvedValueOnce({ document, dom });
}); });
it('emits loadingContent and loadingSuccess event in the eventHub', () => { it('emits loadingContent and loadingSuccess event in the eventHub', () => {
...@@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => { ...@@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => {
expect(loadingContentEmitted).toBe(true); expect(loadingContentEmitted).toBe(true);
}); });
contentEditor.setSerializedContent('**bold text**'); contentEditor.setSerializedContent(testMarkdown);
}); });
it('sets the deserialized document in the tiptap editor object', async () => { 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()); 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', () => { describe('when setSerializedContent fails', () => {
......
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