Commit 6b881013 authored by Enrique Alcantara's avatar Enrique Alcantara Committed by Enrique Alcántara

Load highlight.js languages asynchronously

Change the Content Editor codeblock extension
to load highlight.js languages only when
there are code blocks using that programming
language

Changelog: performance
parent f52ed489
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,35 @@ 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) {
tr.setSelection(selection)
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
if (Object.keys(result).length === 0) {
return;
}
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', () => {
......
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