diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx index aad12e248e..c3f3b1bb6b 100644 --- a/frontend/src/components/Markdown.tsx +++ b/frontend/src/components/Markdown.tsx @@ -2,7 +2,7 @@ import { cn } from '@/lib/utils'; import { omit } from 'lodash'; import { useContext, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; -import { PluggableList } from 'react-markdown/lib'; +import type { PluggableList, RemarkRehypeOptions } from 'react-markdown/lib'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import remarkDirective from 'remark-directive'; @@ -43,6 +43,55 @@ interface Props { className?: string; } +type TextDirectiveHandler = NonNullable< + NonNullable['textDirective'] +>; + +const hasOwn = Object.prototype.hasOwnProperty; + +// Preserve the original syntax unless another plugin explicitly mapped the +// directive to a hast node. +const preserveUnhandledTextDirective: TextDirectiveHandler = (state, node) => { + const data = node.data || {}; + const hasHastData = ['hName', 'hProperties', 'hChildren'].some((key) => + hasOwn.call(data, key) + ); + + if (hasHastData) { + const result = { + type: 'element' as const, + tagName: 'div', + properties: {}, + children: state.all(node) + }; + + state.patch(node, result); + return state.applyData(node, result); + } + + const source = state.options.file?.value; + const start = node.position?.start.offset; + const end = node.position?.end.offset; + const result = { + type: 'text' as const, + value: + typeof source === 'string' && + typeof start === 'number' && + typeof end === 'number' + ? source.slice(start, end) + : `:${node.name}` + }; + + state.patch(node, result); + return result; +}; + +const remarkRehypeOptions: Readonly = { + handlers: { + textDirective: preserveUnhandledTextDirective + } +}; + const cursorPlugin = () => { return (tree: any) => { visit(tree, 'text', (node: any, index, parent) => { @@ -139,6 +188,7 @@ const Markdown = ({ { expect(getByRole('link', { name: 'source(12)' })).toBeInTheDocument(); expect(getByRole('link', { name: 'page{12}' })).toBeInTheDocument(); }); + +it('preserves natural-language colons parsed as text directives', () => { + const content = '💬 TV:n är stor; FN:s huvudkontor; foo:bar'; + const { container } = render( + + ); + + expect(container.textContent).toBe(content); +}); + +it('preserves the complete syntax of unhandled text directives', () => { + const content = + 'A :abbr[lovely language]{title="HyperText Markup Language"}.'; + const { container } = render( + + ); + + expect(container.textContent).toBe(content); +});