Commit 5cad762b authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'render-attribute-definitions' into 'master'

Render markdown attribute definitions as tooltips

See merge request gitlab-org/gitlab!40541
parents 7c540670 d7e21e4b
import { union, mapValues } from 'lodash';
import renderBlockHtml from './renderers/render_html_block';
import renderKramdownList from './renderers/render_kramdown_list';
import renderKramdownText from './renderers/render_kramdown_text';
import renderHeading from './renderers/render_heading';
import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
import renderSoftbreak from './renderers/render_softbreak';
import renderAttributeDefinition from './renderers/render_attribute_definition';
import renderListItem from './renderers/render_list_item';
const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
const htmlBlockRenderers = [renderBlockHtml];
const listRenderers = [renderKramdownList];
const paragraphRenderers = [renderIdentifierParagraph];
const textRenderers = [renderKramdownText, renderIdentifierInstanceText];
const headingRenderers = [renderHeading];
const paragraphRenderers = [renderIdentifierParagraph, renderBlockHtml];
const textRenderers = [renderIdentifierInstanceText, renderAttributeDefinition];
const listItemRenderers = [renderListItem];
const softbreakRenderers = [renderSoftbreak];
const executeRenderer = (renderers, node, context) => {
......@@ -25,7 +27,8 @@ const buildCustomHTMLRenderer = customRenderers => {
...customRenderers,
htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock),
htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline),
list: union(listRenderers, customRenderers?.list),
heading: union(headingRenderers, customRenderers?.heading),
item: union(listItemRenderers, customRenderers?.listItem),
paragraph: union(paragraphRenderers, customRenderers?.paragraph),
text: union(textRenderers, customRenderers?.text),
softbreak: union(softbreakRenderers, customRenderers?.softbreak),
......
......@@ -28,6 +28,7 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
const orderedListItemNode = 'OL LI';
const emphasisNode = 'EM, I';
const strongNode = 'STRONG, B';
const headingNode = 'H1, H2, H3, H4, H5, H6';
return {
TEXT_NODE(node) {
......@@ -63,8 +64,10 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
},
[unorderedListItemNode](node, subContent) {
const baseResult = baseRenderer.convert(node, subContent);
const formatted = baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
const { attributeDefinition } = node.dataset;
return baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
return attributeDefinition ? `${formatted.trimRight()}\n${attributeDefinition}\n` : formatted;
},
[orderedListItemNode](node, subContent) {
const baseResult = baseRenderer.convert(node, subContent);
......@@ -82,6 +85,12 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax);
},
[headingNode](node, subContent) {
const result = baseRenderer.convert(node, subContent);
const { attributeDefinition } = node.dataset;
return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result;
},
};
};
......
import { isAttributeDefinition } from './render_utils';
const canRender = ({ literal }) => isAttributeDefinition(literal);
const render = () => ({ type: 'html', content: '<!-- sse-attribute-definition -->' });
export default { canRender, render };
import {
renderWithAttributeDefinitions as render,
willAlwaysRender as canRender,
} from './render_utils';
export default { render, canRender };
import { renderUneditableBranch as render } from './render_utils';
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;
};
export default { canRender, render };
import { renderUneditableLeaf as render } from './render_utils';
const kramdownRegex = /(^{:.+}$)/;
const canRender = ({ literal }) => {
return kramdownRegex.test(literal);
};
export default { canRender, render };
import {
renderWithAttributeDefinitions as render,
willAlwaysRender as canRender,
} from './render_utils';
export default { render, canRender };
......@@ -8,3 +8,31 @@ export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockToken
export const renderUneditableBranch = (_, { entering, origin }) =>
entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
const attributeDefinitionRegexp = /(^{:.+}$)/;
export const isAttributeDefinition = text => attributeDefinitionRegexp.test(text);
const findAttributeDefinition = node => {
const literal =
node?.next?.firstChild?.literal || node?.firstChild?.firstChild?.next?.next?.literal; // for headings // for list items;
return isAttributeDefinition(literal) ? literal : null;
};
export const renderWithAttributeDefinitions = (node, { origin }) => {
const attributes = findAttributeDefinition(node);
const token = origin();
if (token.type === 'openTag' && attributes) {
Object.assign(token, {
attributes: {
'data-attribute-definition': attributes,
},
});
}
return token;
};
export const willAlwaysRender = () => true;
---
title: Render markdown attribute definitions as tooltips
merge_request: 40541
author:
type: changed
......@@ -5,8 +5,13 @@ describe('Build Custom Renderer Service', () => {
it('should return an object with the default renderer functions when lacking arguments', () => {
expect(buildCustomHTMLRenderer()).toEqual(
expect.objectContaining({
list: expect.any(Function),
htmlBlock: expect.any(Function),
htmlInline: expect.any(Function),
heading: expect.any(Function),
item: expect.any(Function),
paragraph: expect.any(Function),
text: expect.any(Function),
softbreak: expect.any(Function),
}),
);
});
......@@ -20,8 +25,6 @@ describe('Build Custom Renderer Service', () => {
expect(buildCustomHTMLRenderer(customRenderers)).toEqual(
expect.objectContaining({
html: expect.any(Function),
list: expect.any(Function),
text: expect.any(Function),
}),
);
});
......
import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
import { attributeDefinition } from './renderers/mock_data';
describe('HTMLToMarkdownRenderer', () => {
describe('rich_content_editor/services/html_to_markdown_renderer', () => {
let baseRenderer;
let htmlToMarkdownRenderer;
const NODE = { nodeValue: 'mock_node' };
let fakeNode;
beforeEach(() => {
baseRenderer = {
......@@ -12,14 +13,16 @@ describe('HTMLToMarkdownRenderer', () => {
getSpaceControlled: jest.fn(input => `space controlled ${input}`),
convert: jest.fn(),
};
fakeNode = { nodeValue: 'mock_node', dataset: {} };
});
describe('TEXT_NODE visitor', () => {
it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => {
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe(
`space controlled trimmed space collapsed ${NODE.nodeValue}`,
expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe(
`space controlled trimmed space collapsed ${fakeNode.nodeValue}`,
);
});
});
......@@ -43,8 +46,8 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(list);
expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list);
expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list);
});
});
......@@ -62,10 +65,21 @@ describe('HTMLToMarkdownRenderer', () => {
});
baseRenderer.convert.mockReturnValueOnce(listItem);
expect(htmlToMarkdownRenderer['UL LI'](NODE, listItem)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, listItem);
expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem);
},
);
it('detects attribute definitions and attaches them to the list item', () => {
const listItem = '- list item';
const result = `${listItem}\n${attributeDefinition}\n`;
fakeNode.dataset.attributeDefinition = attributeDefinition;
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`);
expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
});
});
describe('OL LI visitor', () => {
......@@ -85,8 +99,8 @@ describe('HTMLToMarkdownRenderer', () => {
});
baseRenderer.convert.mockReturnValueOnce(listItem);
expect(htmlToMarkdownRenderer['OL LI'](NODE, subContent)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, subContent);
expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent);
},
);
});
......@@ -105,8 +119,8 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(input);
expect(htmlToMarkdownRenderer['STRONG, B'](NODE, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input);
expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
},
);
});
......@@ -125,9 +139,22 @@ describe('HTMLToMarkdownRenderer', () => {
baseRenderer.convert.mockReturnValueOnce(input);
expect(htmlToMarkdownRenderer['EM, I'](NODE, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input);
expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result);
expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
},
);
});
describe('H1, H2, H3, H4, H5, H6 visitor', () => {
it('detects attribute definitions and attaches them to the heading', () => {
const heading = 'heading text';
const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`;
fakeNode.dataset.attributeDefinition = attributeDefinition;
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`);
expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result);
});
});
});
......@@ -56,3 +56,5 @@ export const uneditableBlockTokens = [
},
buildMockUneditableCloseToken('div'),
];
export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}';
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition';
import { attributeDefinition } from './mock_data';
describe('rich_content_editor/renderers/render_attribute_definition', () => {
describe('canRender', () => {
it.each`
input | result
${{ literal: attributeDefinition }} | ${true}
${{ literal: `FOO${attributeDefinition}` }} | ${false}
${{ literal: `${attributeDefinition}BAR` }} | ${false}
${{ literal: 'foobar' }} | ${false}
`('returns $result when input is $input', ({ input, result }) => {
expect(renderer.canRender(input)).toBe(result);
});
});
describe('render', () => {
it('returns an empty HTML comment', () => {
expect(renderer.render()).toEqual({
type: 'html',
content: '<!-- sse-attribute-definition -->',
});
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_heading';
import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
describe('rich_content_editor/renderers/render_heading', () => {
it('canRender delegates to renderUtils.willAlwaysRender', () => {
expect(renderer.canRender).toBe(renderUtils.willAlwaysRender);
});
it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list';
import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import { buildMockTextNode } from './mock_data';
const buildMockListNode = literal => {
return {
firstChild: {
firstChild: {
firstChild: buildMockTextNode(literal),
type: 'paragraph',
},
type: 'item',
},
type: 'list',
};
};
const normalListNode = buildMockListNode('Just another bullet point');
const kramdownListNode = buildMockListNode('TOC');
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', () => {
it('should delegate rendering to the renderUneditableBranch util', () => {
expect(renderer.render).toBe(renderUneditableBranch);
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text';
import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import { buildMockTextNode, normalTextNode } from './mock_data';
const kramdownTextNode = buildMockTextNode('{:toc}');
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', () => {
it('should delegate rendering to the renderUneditableLeaf util', () => {
expect(renderer.render).toBe(renderUneditableLeaf);
});
});
});
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_list_item';
import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
describe('rich_content_editor/renderers/render_list_item', () => {
it('canRender delegates to renderUtils.willAlwaysRender', () => {
expect(renderer.canRender).toBe(renderUtils.willAlwaysRender);
});
it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
});
});
import {
renderUneditableLeaf,
renderUneditableBranch,
renderWithAttributeDefinitions,
willAlwaysRender,
} from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import {
......@@ -8,9 +10,9 @@ import {
buildUneditableOpenTokens,
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import { originToken, uneditableCloseToken } from './mock_data';
import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data';
describe('Render utils', () => {
describe('rich_content_editor/renderers/render_utils', () => {
describe('renderUneditableLeaf', () => {
it('should return uneditable block tokens around an origin token', () => {
const context = { origin: jest.fn().mockReturnValueOnce(originToken) };
......@@ -41,4 +43,68 @@ describe('Render utils', () => {
expect(result).toStrictEqual(uneditableCloseToken);
});
});
describe('willAlwaysRender', () => {
it('always returns true', () => {
expect(willAlwaysRender()).toBe(true);
});
});
describe('renderWithAttributeDefinitions', () => {
let openTagToken;
let closeTagToken;
let node;
const attributes = {
'data-attribute-definition': attributeDefinition,
};
beforeEach(() => {
openTagToken = { type: 'openTag' };
closeTagToken = { type: 'closeTag' };
node = {
next: {
firstChild: {
literal: attributeDefinition,
},
},
};
});
describe('when token type is openTag', () => {
it('attaches attributes when attributes exist in the node’s next sibling', () => {
const context = { origin: () => openTagToken };
expect(renderWithAttributeDefinitions(node, context)).toEqual({
...openTagToken,
attributes,
});
});
it('attaches attributes when attributes exist in the node’s children', () => {
const context = { origin: () => openTagToken };
node = {
firstChild: {
firstChild: {
next: {
next: {
literal: attributeDefinition,
},
},
},
},
};
expect(renderWithAttributeDefinitions(node, context)).toEqual({
...openTagToken,
attributes,
});
});
});
it('does not attach attributes when token type is "closeTag"', () => {
const context = { origin: () => closeTagToken };
expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken);
});
});
});
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