Commit 14581ab1 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '220340-custom-renderer-kramdown' into 'master'

Initial kramdown SSE parser

Closes #220340

See merge request gitlab-org/gitlab!34185
parents 5564f6bf 8c4db0dd
import { __ } from '~/locale'; import { __ } from '~/locale';
import { generateToolbarItem } from './editor_service'; import { generateToolbarItem } from './editor_service';
import buildCustomHTMLRenderer from './services/build_custom_renderer';
export const CUSTOM_EVENTS = { export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal', openAddImageModal: 'gl_openAddImageModal',
...@@ -31,6 +32,7 @@ const TOOLBAR_ITEM_CONFIGS = [ ...@@ -31,6 +32,7 @@ const TOOLBAR_ITEM_CONFIGS = [
export const EDITOR_OPTIONS = { export const EDITOR_OPTIONS = {
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)), toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)),
customHTMLRenderer: buildCustomHTMLRenderer(),
}; };
export const EDITOR_TYPES = { export const EDITOR_TYPES = {
......
import renderKramdownList from './renderers/render_kramdown_list';
import renderKramdownText from './renderers/render_kramdown_text';
const listRenderers = [renderKramdownList];
const textRenderers = [renderKramdownText];
const executeRenderer = (renderers, node, context) => {
const availableRenderer = renderers.find(renderer => renderer.canRender(node, context));
return availableRenderer ? availableRenderer.render(context) : context.origin();
};
const buildCustomRendererFunctions = (customRenderers, defaults) => {
const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]);
const customEntries = customTypes.map(type => {
const fn = (node, context) => executeRenderer(customRenderers[type], node, context);
return [type, fn];
});
return Object.fromEntries(customEntries);
};
const buildCustomHTMLRenderer = (customRenderers = { list: [], text: [] }) => {
const defaults = {
list(node, context) {
const allListRenderers = [...customRenderers.list, ...listRenderers];
return executeRenderer(allListRenderers, node, context);
},
text(node, context) {
const allTextRenderers = [...customRenderers.text, ...textRenderers];
return executeRenderer(allTextRenderers, node, context);
},
};
return {
...buildCustomRendererFunctions(customRenderers, defaults),
...defaults,
};
};
export default buildCustomHTMLRenderer;
const buildToken = (type, tagName, props) => {
return { type, tagName, ...props };
};
export const buildUneditableOpenTokens = token => {
return [
buildToken('openTag', 'div', {
attributes: { contenteditable: false },
classNames: [
'gl-px-4 gl-py-2 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
],
}),
token,
];
};
export const buildUneditableCloseToken = () => buildToken('closeTag', 'div');
export const buildUneditableTokens = token => {
return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()];
};
import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC';
const canRender = node => {
let targetNode = node;
while (targetNode !== null) {
const { firstChild } = targetNode;
const isLeaf = firstChild === null;
if (isLeaf) {
if (isKramdownTOC(targetNode)) {
return true;
}
break;
}
targetNode = targetNode.firstChild;
}
return false;
};
const render = ({ entering, origin }) =>
entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
export default { canRender, render };
import { buildUneditableTokens } from './build_uneditable_token';
const canRender = ({ literal }) => {
const kramdownRegex = /(^{:.+}$)/gm;
return kramdownRegex.test(literal);
};
const render = ({ origin }) => {
return buildUneditableTokens(origin());
};
export default { canRender, render };
---
title: "Prevents editing of non-markdown kramdown content in the Static Site Editor's WYSIWYG mode"
merge_request: 34185
author:
type: changed
const buildMockTextNode = literal => {
return {
firstChild: null,
literal,
type: 'text',
};
};
const buildMockListNode = literal => {
return {
firstChild: {
firstChild: {
firstChild: buildMockTextNode(literal),
type: 'paragraph',
},
type: 'item',
},
type: 'list',
};
};
export const kramdownListNode = buildMockListNode('TOC');
export const normalListNode = buildMockListNode('Just another bullet point');
export const kramdownTextNode = buildMockTextNode('{:toc}');
export const normalTextNode = buildMockTextNode('This is just normal text.');
const uneditableOpenToken = {
type: 'openTag',
tagName: 'div',
attributes: { contenteditable: false },
classNames: [
'gl-px-4 gl-py-2 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
],
};
export const uneditableCloseToken = { type: 'closeTag', tagName: 'div' };
export const originToken = {
type: 'text',
content: '{:.no_toc .hidden-md .hidden-lg}',
};
export const uneditableOpenTokens = [uneditableOpenToken, originToken];
export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken];
import buildCustomHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer';
describe('Build Custom Renderer Service', () => {
describe('buildCustomHTMLRenderer', () => {
it('should return an object with the default renderer functions when lacking arguments', () => {
expect(buildCustomHTMLRenderer()).toEqual(
expect.objectContaining({
list: expect.any(Function),
text: expect.any(Function),
}),
);
});
it('should return an object with both custom and default renderer functions when passed customRenderers', () => {
const mockHtmlCustomRenderer = jest.fn();
const customRenderers = {
html: [mockHtmlCustomRenderer],
};
expect(buildCustomHTMLRenderer(customRenderers)).toEqual(
expect.objectContaining({
html: expect.any(Function),
list: expect.any(Function),
text: expect.any(Function),
}),
);
});
});
});
import {
buildUneditableOpenTokens,
buildUneditableCloseToken,
buildUneditableTokens,
} from '~/vue_shared/components/rich_content_editor/services/renderers//build_uneditable_token';
import {
originToken,
uneditableOpenTokens,
uneditableCloseToken,
uneditableTokens,
} from '../../mock_data';
describe('Build Uneditable Token renderer helper', () => {
describe('buildUneditableOpenTokens', () => {
it('returns a 2-item array of tokens with the originToken appended to an open token', () => {
const result = buildUneditableOpenTokens(originToken);
expect(result).toHaveLength(2);
expect(result).toStrictEqual(uneditableOpenTokens);
});
});
describe('buildUneditableCloseToken', () => {
it('returns an object literal representing the uneditable close token', () => {
expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken);
});
});
describe('buildUneditableTokens', () => {
it('returns a 3-item array of tokens with the originToken wrapped in the middle', () => {
const result = buildUneditableTokens(originToken);
expect(result).toHaveLength(3);
expect(result).toStrictEqual(uneditableTokens);
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list';
import {
buildUneditableOpenTokens,
buildUneditableCloseToken,
} from '~/vue_shared/components/rich_content_editor/services/renderers//build_uneditable_token';
import { kramdownListNode, normalListNode } from '../../mock_data';
describe('Render Kramdown List renderer', () => {
describe('canRender', () => {
it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => {
expect(renderer.canRender(kramdownListNode)).toBe(true);
});
it('should return false when the argument is a normal ordered/unordered list', () => {
expect(renderer.canRender(normalListNode)).toBe(false);
});
});
describe('render', () => {
const origin = jest.fn();
it('should return uneditable open tokens when entering', () => {
const context = { entering: true, origin };
expect(renderer.render(context)).toStrictEqual(buildUneditableOpenTokens(origin()));
});
it('should return an uneditable close tokens when exiting', () => {
const context = { entering: false, origin };
expect(renderer.render(context)).toStrictEqual(buildUneditableCloseToken(origin()));
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text';
import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers//build_uneditable_token';
import { kramdownTextNode, normalTextNode } from '../../mock_data';
describe('Render Kramdown Text renderer', () => {
describe('canRender', () => {
it('should return true when the argument `literal` has kramdown syntax', () => {
expect(renderer.canRender(kramdownTextNode)).toBe(true);
});
it('should return false when the argument `literal` lacks kramdown syntax', () => {
expect(renderer.canRender(normalTextNode)).toBe(false);
});
});
describe('render', () => {
const origin = jest.fn();
it('should return uneditable tokens', () => {
const context = { origin };
expect(renderer.render(context)).toStrictEqual(buildUneditableTokens(origin()));
});
});
});
...@@ -1138,20 +1138,20 @@ ...@@ -1138,20 +1138,20 @@
dependencies: dependencies:
defer-to-connect "^1.0.1" defer-to-connect "^1.0.1"
"@toast-ui/editor@^2.0.1": "@toast-ui/editor@2.1.2", "@toast-ui/editor@^2.1.2":
version "2.0.1" version "2.1.2"
resolved "https://registry.yarnpkg.com/@toast-ui/editor/-/editor-2.0.1.tgz#749e5be1f02f42ded51488d1575ab1c19ca59952" resolved "https://registry.yarnpkg.com/@toast-ui/editor/-/editor-2.1.2.tgz#0472431bd039ae70882d77910e83f0ad222d0b1c"
integrity sha512-TC481O/zP37boY6H6oVN6KLVMY7yrU8zQu+3xqZ71V3Sr6D2XyaGb2Xub9XqTdqzBmzsf7y4Gi+EXO0IQ3rGVA== integrity sha512-yoWRVyp2m1dODH+bmzJaILUgl2L57GCQJ8c8+XRgJMwfxb/TFz5U+oT8JGAU5VwozIzKF0SyVMs8AEePwwhIIA==
dependencies: dependencies:
"@types/codemirror" "0.0.71" "@types/codemirror" "0.0.71"
codemirror "^5.48.4" codemirror "^5.48.4"
"@toast-ui/vue-editor@^2.0.1": "@toast-ui/vue-editor@2.1.2":
version "2.0.1" version "2.1.2"
resolved "https://registry.yarnpkg.com/@toast-ui/vue-editor/-/vue-editor-2.0.1.tgz#c9c8c8da4c0a67b9fbc4240464388c67d72a0c22" resolved "https://registry.yarnpkg.com/@toast-ui/vue-editor/-/vue-editor-2.1.2.tgz#a790e69fcf7fb426e6b8ea190733477c3cc756aa"
integrity sha512-sGsApl0n+GVAZbmPA+tTrq9rmmyh2mRgCgg2/mu1/lN7S4vPv/nQH8KXxLG9Y6hG2+kgelqz6wvbOCdzlM/HmQ== integrity sha512-RK01W6D8FqtNq4MjWsXk6KRzOU/vL6mpiADAnH5l/lFK4G6UQJhLKsMRfmxIqCH+ivm8VtQzGdd9obUfD+XbCw==
dependencies: dependencies:
"@toast-ui/editor" "^2.0.1" "@toast-ui/editor" "^2.1.2"
"@types/anymatch@*": "@types/anymatch@*":
version "1.3.0" version "1.3.0"
......
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