Commit 6b553241 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '225179-paragraph-custom-renderer-for-identifiers' into 'master'

Initial identifier paragraph custom renderer implementation

Closes #225179

See merge request gitlab-org/gitlab!35881
parents f5cec815 d8a24c79
import renderKramdownList from './renderers/render_kramdown_list';
import renderKramdownText from './renderers/render_kramdown_text';
import renderIdentifierText from './renderers/render_identifier_text';
import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text';
const listRenderers = [renderKramdownList];
const textRenderers = [renderKramdownText, renderIdentifierText, renderEmbeddedRubyText];
const paragraphRenderers = [renderIdentifierParagraph];
const textRenderers = [renderKramdownText, renderEmbeddedRubyText];
const executeRenderer = (renderers, node, context) => {
const availableRenderer = renderers.find(renderer => renderer.canRender(node, context));
......@@ -22,13 +23,18 @@ const buildCustomRendererFunctions = (customRenderers, defaults) => {
return Object.fromEntries(customEntries);
};
const buildCustomHTMLRenderer = (customRenderers = { list: [], text: [] }) => {
const buildCustomHTMLRenderer = (customRenderers = { list: [], paragraph: [], text: [] }) => {
const defaults = {
list(node, context) {
const allListRenderers = [...customRenderers.list, ...listRenderers];
return executeRenderer(allListRenderers, node, context);
},
paragraph(node, context) {
const allParagraphRenderers = [...customRenderers.list, ...paragraphRenderers];
return executeRenderer(allParagraphRenderers, node, context);
},
text(node, context) {
const allTextRenderers = [...customRenderers.text, ...textRenderers];
......
import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
const identifierRegex = /(^\[.+\]: .+)/;
const isIdentifier = text => {
return identifierRegex.test(text);
};
const canRender = (node, context) => {
return isIdentifier(context.getChildrenText(node));
};
const render = (_, { entering, origin }) =>
entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
export default { canRender, render };
import {
buildUneditableOpenTokens,
buildUneditableCloseTokens,
buildUneditableTokens,
} from './build_uneditable_token';
const identifierRegex = /(^\[.+\]: .+)/;
const isBasicIdentifier = ({ literal }) => {
return identifierRegex.test(literal);
};
const isInlineCodeNode = ({ type, tickCount }) => type === 'code' && tickCount === 1;
const hasAdjacentInlineCode = (isForward, node) => {
const direction = isForward ? 'next' : 'prev';
let currentNode = node;
while (currentNode[direction] && currentNode.literal !== null) {
if (isInlineCodeNode(currentNode)) {
return true;
}
currentNode = currentNode[direction];
}
return false;
};
const hasEnteringPotential = literal => literal.includes('[');
const hasExitingPotential = literal => literal.includes(']: ');
const hasAdjacentExit = node => {
let currentNode = node;
while (currentNode && currentNode.literal !== null) {
if (hasExitingPotential(currentNode.literal)) {
return true;
}
currentNode = currentNode.next;
}
return false;
};
const isEnteringWithAdjacentInlineCode = ({ literal, next }) => {
if (next && hasEnteringPotential(literal) && !hasExitingPotential(literal)) {
return hasAdjacentInlineCode(true, next) && hasAdjacentExit(next);
}
return false;
};
const isExitingWithAdjacentInlineCode = ({ literal, prev }) => {
if (prev && !hasEnteringPotential(literal) && hasExitingPotential(literal)) {
return hasAdjacentInlineCode(false, prev);
}
return false;
};
const isAdjacentInlineCodeIdentifier = node => {
return isEnteringWithAdjacentInlineCode(node) || isExitingWithAdjacentInlineCode(node);
};
const canRender = (node, context) => {
return isBasicIdentifier(node) || isAdjacentInlineCodeIdentifier(node, context);
};
const render = (node, { origin }) => {
if (isEnteringWithAdjacentInlineCode(node)) {
return buildUneditableOpenTokens(origin());
} else if (isExitingWithAdjacentInlineCode(node)) {
return buildUneditableCloseTokens(origin());
}
return buildUneditableTokens(origin());
};
export default { canRender, render };
......@@ -6,25 +6,6 @@ const buildMockTextNode = literal => {
};
};
const buildMockTextNodeWithAdjacentInlineCode = isForward => {
const direction = isForward ? 'next' : 'prev';
const literalOpen = '[';
const literalClose = ' file]: https://file.com/file.md';
return {
literal: isForward ? literalOpen : literalClose,
type: 'text',
[direction]: {
literal: 'raw',
tickCount: 1,
type: 'code',
[direction]: {
literal: isForward ? literalClose : literalOpen,
[direction]: null,
},
},
};
};
const buildMockListNode = literal => {
return {
firstChild: {
......@@ -38,15 +19,23 @@ const buildMockListNode = literal => {
};
};
export const buildMockParagraphNode = literal => {
return {
firstChild: buildMockTextNode(literal),
type: 'paragraph',
};
};
export const kramdownListNode = buildMockListNode('TOC');
export const normalListNode = buildMockListNode('Just another bullet point');
export const kramdownTextNode = buildMockTextNode('{:toc}');
export const identifierTextNode = buildMockTextNode('[Some text]: https://link.com');
export const identifierInlineCodeTextEnteringNode = buildMockTextNodeWithAdjacentInlineCode(true);
export const identifierInlineCodeTextExitingNode = buildMockTextNodeWithAdjacentInlineCode(false);
export const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>');
export const normalTextNode = buildMockTextNode('This is just normal text.');
export const normalParagraphNode = buildMockParagraphNode(
'This is just normal paragraph. It has multiple sentences.',
);
const uneditableOpenToken = {
type: 'openTag',
......
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph';
import {
buildUneditableOpenTokens,
buildUneditableCloseToken,
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import { buildMockParagraphNode, normalParagraphNode } from '../../mock_data';
const identifierParagraphNode = buildMockParagraphNode(
`[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`,
);
describe('Render Identifier Paragraph renderer', () => {
describe('canRender', () => {
it.each`
node | paragraph | target
${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true}
${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false}
`(
'should return $target when the $node matches $paragraph syntax',
({ node, paragraph, target }) => {
const context = {
entering: true,
getChildrenText: jest.fn().mockReturnValueOnce(paragraph),
};
expect(renderer.canRender(node, context)).toBe(target);
},
);
});
describe('render', () => {
let origin;
beforeEach(() => {
origin = jest.fn();
});
it('should return uneditable open tokens when entering', () => {
const context = { entering: true, origin };
expect(renderer.render(identifierParagraphNode, context)).toStrictEqual(
buildUneditableOpenTokens(origin()),
);
});
it('should return an uneditable close tokens when exiting', () => {
const context = { entering: false, origin };
expect(renderer.render(identifierParagraphNode, context)).toStrictEqual(
buildUneditableCloseToken(origin()),
);
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_text';
import {
buildUneditableOpenTokens,
buildUneditableCloseTokens,
buildUneditableTokens,
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import {
identifierTextNode,
identifierInlineCodeTextEnteringNode,
identifierInlineCodeTextExitingNode,
normalTextNode,
} from '../../mock_data';
describe('Render Identifier Text renderer', () => {
describe('canRender', () => {
it('should return true when the argument `literal` has identifier syntax', () => {
expect(renderer.canRender(identifierTextNode)).toBe(true);
});
it('should return true when the argument `literal` has identifier syntax and forward adjacent inline code', () => {
expect(renderer.canRender(identifierInlineCodeTextEnteringNode)).toBe(true);
});
it('should return true when the argument `literal` has identifier syntax and backward adjacent inline code', () => {
expect(renderer.canRender(identifierInlineCodeTextExitingNode)).toBe(true);
});
it('should return false when the argument `literal` lacks identifier syntax', () => {
expect(renderer.canRender(normalTextNode)).toBe(false);
});
});
describe('render', () => {
const origin = jest.fn();
it('should return uneditable tokens for basic identifier syntax', () => {
const context = { origin };
expect(renderer.render(identifierTextNode, context)).toStrictEqual(
buildUneditableTokens(origin()),
);
});
it('should return uneditable open tokens for non-basic inline code identifier syntax when entering', () => {
const context = { origin };
expect(renderer.render(identifierInlineCodeTextEnteringNode, context)).toStrictEqual(
buildUneditableOpenTokens(origin()),
);
});
it('should return uneditable close tokens for non-basic inline code identifier syntax when exiting', () => {
const context = { origin };
expect(renderer.render(identifierInlineCodeTextExitingNode, context)).toStrictEqual(
buildUneditableCloseTokens(origin()),
);
});
});
});
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