diff options
Diffstat (limited to 'app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers')
11 files changed, 250 insertions, 0 deletions
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js new file mode 100644 index 00000000000..638e5fd6f60 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js @@ -0,0 +1,63 @@ +const buildToken = (type, tagName, props) => { + return { type, tagName, ...props }; +}; + +const TAG_TYPES = { + block: 'div', + inline: 'a', +}; + +// Open helpers (singular and multiple) + +const buildUneditableOpenToken = (tagType = TAG_TYPES.block) => + buildToken('openTag', tagType, { + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }); + +export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => { + return [buildUneditableOpenToken(tagType), token]; +}; + +// Close helpers (singular and multiple) + +export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) => + buildToken('closeTag', tagType); + +export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => { + return [token, buildUneditableCloseToken(tagType)]; +}; + +// Complete helpers (open plus close) + +export const buildTextToken = (content) => buildToken('text', null, { content }); + +export const buildUneditableBlockTokens = (token) => { + return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()]; +}; + +export const buildUneditableInlineTokens = (token) => { + return [ + ...buildUneditableOpenTokens(token, TAG_TYPES.inline), + buildUneditableCloseToken(TAG_TYPES.inline), + ]; +}; + +export const buildUneditableHtmlAsTextTokens = (node) => { + /* + Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain + nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want + to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html` + type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass ` + to prevent their persistence within the `text` content as the user did not intend these as edits. + + https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72 + */ + const regex = / data-tomark-pass /gm; + const content = node.literal.replace(regex, ''); + const htmlAsTextToken = buildToken('text', null, { content }); + + return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()]; +}; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js new file mode 100644 index 00000000000..bd419447a48 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js @@ -0,0 +1,7 @@ +import { isAttributeDefinition } from './render_utils'; + +const canRender = ({ literal }) => isAttributeDefinition(literal); + +const render = () => ({ type: 'html', content: '<!-- sse-attribute-definition -->' }); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js new file mode 100644 index 00000000000..0e122f598e5 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js @@ -0,0 +1,9 @@ +import { renderUneditableLeaf as render } from './render_utils'; + +const embeddedRubyRegex = /(^<%.+%>$)/; + +const canRender = ({ literal }) => { + return embeddedRubyRegex.test(literal); +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js new file mode 100644 index 00000000000..572f6e3cf9d --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js @@ -0,0 +1,11 @@ +import { buildUneditableInlineTokens } from './build_uneditable_token'; + +const fontAwesomeRegexOpen = /<i class="fa.+>/; + +const canRender = ({ literal }) => { + return fontAwesomeRegexOpen.test(literal); +}; + +const render = (_, { origin }) => buildUneditableInlineTokens(origin()); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js new file mode 100644 index 00000000000..71026fd0d65 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js @@ -0,0 +1,6 @@ +import { + renderWithAttributeDefinitions as render, + willAlwaysRender as canRender, +} from './render_utils'; + +export default { render, canRender }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js new file mode 100644 index 00000000000..710b807275b --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js @@ -0,0 +1,23 @@ +import { getURLOrigin } from '~/lib/utils/url_utility'; +import { ALLOWED_VIDEO_ORIGINS } from '../../constants'; +import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; + +const isVideoFrame = (html) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const { + children: { length }, + } = doc; + const iframe = doc.querySelector('iframe'); + const origin = iframe && getURLOrigin(iframe.getAttribute('src')); + + return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin); +}; + +const canRender = ({ type, literal }) => { + return type === 'htmlBlock' && !isVideoFrame(literal); +}; + +const render = (node) => buildUneditableHtmlAsTextTokens(node); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js new file mode 100644 index 00000000000..d770dd18d7f --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -0,0 +1,40 @@ +import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token'; + +/* +Use case examples: +- Majority: two bracket pairs, back-to-back, each with content (including spaces) + - `[environment terraform plans][terraform]` + - `[an issue labelled `~"main:broken"`][broken-main-issues]` +- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces) + - `[this link][]` + - `[this link]` + +Regexp notes: + - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces) + - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces) + - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`) + - Each of the three parts is non-captured, but the match as a whole is captured +*/ +const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g; + +const isIdentifierInstance = (literal) => { + // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448) + identifierInstanceRegex.lastIndex = 0; + return identifierInstanceRegex.test(literal); +}; + +const canRender = ({ literal }) => isIdentifierInstance(literal); + +const tokenize = (text) => { + const matches = text.split(identifierInstanceRegex); + const tokens = matches.map((match) => { + const token = buildTextToken(match); + return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token; + }); + + return tokens.flat(); +}; + +const render = (_, { origin }) => tokenize(origin().content); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js new file mode 100644 index 00000000000..4829f0f2243 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js @@ -0,0 +1,40 @@ +const identifierRegex = /(^\[.+\]: .+)/; + +const isIdentifier = (text) => { + return identifierRegex.test(text); +}; + +const canRender = (node, context) => { + return isIdentifier(context.getChildrenText(node)); +}; + +const getReferenceDefinitions = (node, definitions = '') => { + if (!node) { + return definitions; + } + + const definition = node.type === 'text' ? node.literal : '\n'; + + return getReferenceDefinitions(node.next, `${definitions}${definition}`); +}; + +const render = (node, { skipChildren }) => { + const content = getReferenceDefinitions(node.firstChild); + + skipChildren(); + + return [ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { 'data-sse-reference-definition': true }, + }, + { type: 'openTag', tagName: 'code' }, + { type: 'text', content }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]; +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js new file mode 100644 index 00000000000..71026fd0d65 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js @@ -0,0 +1,6 @@ +import { + renderWithAttributeDefinitions as render, + willAlwaysRender as canRender, +} from './render_utils'; + +export default { render, canRender }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js new file mode 100644 index 00000000000..c004e839821 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js @@ -0,0 +1,7 @@ +const canRender = (node) => ['emph', 'strong'].includes(node.parent?.type); +const render = () => ({ + type: 'text', + content: ' ', +}); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js new file mode 100644 index 00000000000..eff5dbf59f2 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js @@ -0,0 +1,38 @@ +import { + buildUneditableBlockTokens, + buildUneditableOpenTokens, + buildUneditableCloseToken, +} from './build_uneditable_token'; + +export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin()); + +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; |