diff --git a/packages/eui/changelogs/upcoming/9408.md b/packages/eui/changelogs/upcoming/9408.md new file mode 100644 index 000000000000..24c0e4319177 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9408.md @@ -0,0 +1,4 @@ +**Bug fixes** + +- Fixed support for intraword underscores in `EuiMarkdownFormat` + diff --git a/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts b/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts index 702605927ddb..c0ce4fecb454 100644 --- a/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts +++ b/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts @@ -26,6 +26,7 @@ import markdown from 'remark-parse-no-trim'; import emoji from 'remark-emoji'; import breaks from 'remark-breaks'; import highlight from '../remark/remark_prismjs'; +import intrawordUnderscore from '../remark/remark_intraword_underscore'; import * as MarkdownTooltip from '../markdown_tooltip'; import * as MarkdownCheckbox from '../markdown_checkbox'; import { @@ -69,6 +70,7 @@ export const getDefaultEuiMarkdownParsingPlugins = ({ const parsingPlugins: PluggableList = [ [markdown, {}], [highlight, {}], + [intrawordUnderscore, {}], ]; Object.entries(DEFAULT_PARSING_PLUGINS).forEach(([pluginName, plugin]) => { diff --git a/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts b/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts index 211b95a6f769..e6117d8a4934 100644 --- a/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts +++ b/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts @@ -13,7 +13,7 @@ describe('default plugins', () => { const { parsingPlugins, processingPlugins, uiPlugins } = getDefaultEuiMarkdownPlugins(); - expect(parsingPlugins).toHaveLength(7); + expect(parsingPlugins).toHaveLength(8); expect(Object.keys(processingPlugins[1][1].components)).toHaveLength(8); expect(uiPlugins).toHaveLength(1); @@ -27,7 +27,7 @@ describe('default plugins', () => { exclude: ['tooltip'], }); - expect(parsingPlugins).toHaveLength(6); + expect(parsingPlugins).toHaveLength(7); expect(processingPlugins[1][1].components.tooltipPlugin).toBeUndefined(); expect(uiPlugins).toHaveLength(0); }); @@ -38,7 +38,7 @@ describe('default plugins', () => { exclude: ['checkbox'], }); - expect(parsingPlugins).toHaveLength(6); + expect(parsingPlugins).toHaveLength(7); expect(processingPlugins[1][1].components.checkboxPlugin).toBeUndefined(); expect(uiPlugins).toHaveLength(1); }); @@ -55,7 +55,7 @@ describe('default plugins', () => { ], }); - expect(parsingPlugins).toHaveLength(2); + expect(parsingPlugins).toHaveLength(3); expect(Object.keys(processingPlugins[1][1].components)).toHaveLength(6); expect(uiPlugins).toHaveLength(0); }); diff --git a/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.test.tsx b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.test.tsx new file mode 100644 index 000000000000..3e9c8ad4a737 --- /dev/null +++ b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '../../../../test/rtl'; +import { EuiMarkdownFormat } from '../../index'; + +describe('remarkIntrawordUnderscore', () => { + it('preserves identifiers with double underscores as plain text', () => { + const { container } = render( + + {`ABDC__AppleBanana__c + ABDC__MangoKiwi__c + ABDC__PineappleCherry__c`} + + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('ABDC__AppleBanana__c'); + expect(container).toHaveTextContent('ABDC__MangoKiwi__c'); + expect(container).toHaveTextContent('ABDC__PineappleCherry__c'); + }); + + it('preserves identifiers with single underscores as plain text', () => { + const { container } = render( + {'some_variable_name'} + ); + + expect(container.querySelector('em')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('some_variable_name'); + }); + + it('still applies bold for standalone double underscores', () => { + const { container } = render( + {'__bold text__'} + ); + + expect(container.querySelector('strong')).toHaveTextContent('bold text'); + }); + + it('still applies emphasis for standalone single underscores', () => { + const { container } = render( + {'_italic text_'} + ); + + expect(container.querySelector('em')).toHaveTextContent('italic text'); + }); + + it('handles multiple identifiers in a sentence', () => { + const { container } = render( + + {'Fields ABDC__AppleBanana__c and ABDC__MangoKiwi__c are required'} + + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent( + 'Fields ABDC__AppleBanana__c and ABDC__MangoKiwi__c are required' + ); + }); + + it('preserves trailing double underscores as plain text', () => { + const { container } = render( + {'Mango__Kiwi__'} + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('Mango__Kiwi__'); + }); + + it('preserves leading double underscores as plain text', () => { + const { container } = render( + {'__Mango__Kiwi'} + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('__Mango__Kiwi'); + }); + + it('preserves trailing single underscores as plain text', () => { + const { container } = render( + {'Mango_Kiwi_'} + ); + + expect(container.querySelector('em')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('Mango_Kiwi_'); + }); + + it('preserves mixed double/single trailing underscores as plain text', () => { + const { container } = render( + {'Mango__Kiwi_'} + ); + + expect(container.querySelector('em')).not.toBeInTheDocument(); + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('Mango__Kiwi_'); + }); + + it('handles edge-case identifiers mixed in a sentence', () => { + const { container } = render( + + {'Check __Mango__Kiwi and Mango__Kiwi__ fields'} + + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent( + 'Check __Mango__Kiwi and Mango__Kiwi__ fields' + ); + }); +}); diff --git a/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.ts b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.ts new file mode 100644 index 000000000000..b64c050ec078 --- /dev/null +++ b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// Temporary workaround for https://github.com/elastic/eui/issues/9404 +// remark-parse v8 doesn't implement the CommonMark rule that underscore +// emphasis delimiters flanked by alphanumerics on both sides (intraword) +// cannot open or close emphasis. This causes identifiers like +// `ABCD__PineappleCherry__e` to render with bold formatting. +// This plugin walks the parsed AST and reverses incorrectly applied +// emphasis/strong nodes when both sides are alphanumeric characters. + +import { Plugin } from 'unified'; +// eslint-disable-next-line import/no-unresolved +import { Node, Parent } from 'unist'; + +interface TextNode extends Node { + type: 'text'; + value: string; +} + +interface EmphasisOrStrong extends Parent { + type: 'emphasis' | 'strong'; +} + +const isTextNode = (node: Node): node is TextNode => node.type === 'text'; + +const isEmphasisOrStrong = (node: Node): node is EmphasisOrStrong => + node.type === 'emphasis' || node.type === 'strong'; + +const isAlphanumeric = (ch: string): boolean => /[a-zA-Z0-9]/.test(ch); + +/** + * Recursively converts an emphasis/strong node back into plain text + * with underscore delimiters restored. + */ +function flattenToText(node: EmphasisOrStrong): string { + const delimiter = node.type === 'emphasis' ? '_' : '__'; + let inner = ''; + for (const child of node.children) { + if (isTextNode(child)) { + inner += child.value; + } else if (isEmphasisOrStrong(child)) { + inner += flattenToText(child); + } else { + // Contains non-text children (links, images, etc.) — leave emphasis intact + return delimiter + collectText(node) + delimiter; + } + } + return delimiter + inner + delimiter; +} + +function collectText(node: Node): string { + if (isTextNode(node)) return node.value; + if ('children' in node) { + return (node as Parent).children.map(collectText).join(''); + } + return ''; +} + +function processParent(parent: Parent, source: string) { + let modified = false; + let i = 0; + + while (i < parent.children.length) { + const child = parent.children[i]; + + if (isEmphasisOrStrong(child) && isIntraword(parent, i, source)) { + const textValue = flattenToText(child); + const replacement: TextNode = { + type: 'text', + value: textValue, + } as TextNode; + + parent.children.splice(i, 1, replacement); + modified = true; + // Don't advance — the replaced node may need to merge with neighbors + } else { + if ('children' in child) { + processParent(child as Parent, source); + } + i++; + } + + if (modified) { + mergeAdjacentText(parent); + modified = false; + // After merging, restart scan since indices shifted + i = 0; + } + } +} + +function getInnerText(node: Node): string { + if (isTextNode(node)) return node.value; + if ('children' in node) { + const children = (node as Parent).children; + if (children.length > 0) return getInnerText(children[0]); + } + return ''; +} + +function getInnerTextEnd(node: Node): string { + if (isTextNode(node)) return node.value; + if ('children' in node) { + const children = (node as Parent).children; + if (children.length > 0) + return getInnerTextEnd(children[children.length - 1]); + } + return ''; +} + +/** + * Checks whether the emphasis/strong node at `index` within `parent` + * is an intraword usage of underscore delimiters. + * + * A node is intraword when at least one side has an alphanumeric text + * neighbor AND the inner content on the corresponding delimiter side + * also touches word characters — proving the underscores are embedded + * in a word rather than used as formatting. + */ +function isIntraword(parent: Parent, index: number, source: string): boolean { + const node = parent.children[index]; + + // Verify the delimiter is `_` (not `*`) by inspecting the source + if (node.position?.start?.offset != null) { + const ch = source[node.position.start.offset]; + if (ch !== '_') return false; + } + + const prev = index > 0 ? parent.children[index - 1] : null; + const next = + index < parent.children.length - 1 ? parent.children[index + 1] : null; + + const prevChar = + prev != null && isTextNode(prev) && prev.value.length > 0 + ? prev.value[prev.value.length - 1] + : null; + + const nextChar = + next != null && isTextNode(next) && next.value.length > 0 + ? next.value[0] + : null; + + const prevIsAlpha = prevChar != null && isAlphanumeric(prevChar); + const nextIsAlpha = nextChar != null && isAlphanumeric(nextChar); + + // Both sides flanked by alphanumeric — classic intraword (e.g. `foo__bar__baz`) + if (prevIsAlpha && nextIsAlpha) return true; + + // One-sided: prev is alpha or underscore, no alpha next — check inner text + // starts with alpha (e.g. `Lorem__ipsum__` or `Lorem__ipsum_`) + if (prevIsAlpha || prevChar === '_') { + const inner = getInnerText(node); + if (inner.length > 0 && isAlphanumeric(inner[0])) return true; + } + + // One-sided: next is alpha or underscore, no alpha prev — check inner text + // ends with alpha (e.g. `__Lorem__ipsum` or `_Lorem__ipsum`) + if (nextIsAlpha || nextChar === '_') { + const inner = getInnerTextEnd(node); + if (inner.length > 0 && isAlphanumeric(inner[inner.length - 1])) + return true; + } + + return false; +} + +function mergeAdjacentText(parent: Parent) { + let i = 0; + while (i < parent.children.length - 1) { + if (isTextNode(parent.children[i]) && isTextNode(parent.children[i + 1])) { + (parent.children[i] as TextNode).value += ( + parent.children[i + 1] as TextNode + ).value; + parent.children.splice(i + 1, 1); + } else { + i++; + } + } +} + +const attacher: Plugin = function remarkIntrawordUnderscore() { + return (tree, file) => { + const source = String(file); + processParent(tree as Parent, source); + }; +}; + +export default attacher;