From 885e96636dd51ff057e8a45e60d2b76fb13a4544 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 15:48:03 -0700 Subject: [PATCH] fix(richtext-lexical): formatted link markdown conversion not working (#10269) Fixes https://github.com/payloadcms/payload/issues/8279 Ports over https://github.com/facebook/lexical/pull/7004 --- .../src/features/link/markdownTransformer.ts | 19 +- .../@lexical/markdown/MarkdownExport.ts | 121 +++++++++++-- .../@lexical/markdown/MarkdownImport.ts | 165 +----------------- .../@lexical/markdown/MarkdownTransformers.ts | 5 +- .../markdown/importTextFormatTransformer.ts | 134 ++++++++++++++ .../markdown/importTextMatchTransformer.ts | 105 +++++++++++ .../markdown/importTextTransformers.ts | 120 +++++++++++++ 7 files changed, 483 insertions(+), 186 deletions(-) create mode 100644 packages/richtext-lexical/src/packages/@lexical/markdown/importTextFormatTransformer.ts create mode 100644 packages/richtext-lexical/src/packages/@lexical/markdown/importTextMatchTransformer.ts create mode 100644 packages/richtext-lexical/src/packages/@lexical/markdown/importTextTransformers.ts diff --git a/packages/richtext-lexical/src/features/link/markdownTransformer.ts b/packages/richtext-lexical/src/features/link/markdownTransformer.ts index 19a15399253..942cb00476f 100644 --- a/packages/richtext-lexical/src/features/link/markdownTransformer.ts +++ b/packages/richtext-lexical/src/features/link/markdownTransformer.ts @@ -16,21 +16,18 @@ import { $createLinkNode, $isLinkNode, LinkNode } from './nodes/LinkNode.js' export const LinkMarkdownTransformer: TextMatchTransformer = { type: 'text-match', dependencies: [LinkNode], - export: (_node, exportChildren, exportFormat) => { + export: (_node, exportChildren) => { if (!$isLinkNode(_node)) { return null } const node: LinkNode = _node const { url } = node.getFields() - const linkContent = `[${node.getTextContent()}](${url})` - const firstChild = node.getFirstChild() - // Add text styles only if link has single text node inside. If it's more - // then one we ignore it as markdown does not support nested styles for links - if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) { - return exportFormat(firstChild, linkContent) - } else { - return linkContent - } + + const textContent = exportChildren(node) + + const linkContent = `[${textContent}](${url})` + + return linkContent }, importRegExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)/, regExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)$/, @@ -48,6 +45,8 @@ export const LinkMarkdownTransformer: TextMatchTransformer = { linkTextNode.setFormat(textNode.getFormat()) linkNode.append(linkTextNode) textNode.replace(linkNode) + + return linkTextNode }, trigger: ')', } diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts index e5448f4039a..90d460eaba9 100644 --- a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts +++ b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts @@ -38,7 +38,7 @@ export function createMarkdownExport( ) return (node) => { - const output: string[] = [] + const output = [] const children = (node || $getRoot()).getChildren() for (let i = 0; i < children.length; i++) { @@ -100,9 +100,18 @@ function exportChildren( node: ElementNode, textTransformersIndex: Array, textMatchTransformers: Array, + unclosedTags?: Array<{ format: TextFormatType; tag: string }>, + unclosableTags?: Array<{ format: TextFormatType; tag: string }>, ): string { - const output: string[] = [] + const output = [] const children = node.getChildren() + // keep track of unclosed tags from the very beginning + if (!unclosedTags) { + unclosedTags = [] + } + if (!unclosableTags) { + unclosableTags = [] + } mainLoop: for (const child of children) { for (const transformer of textMatchTransformers) { @@ -112,8 +121,27 @@ function exportChildren( const result = transformer.export( child, - (parentNode) => exportChildren(parentNode, textTransformersIndex, textMatchTransformers), - (textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex), + (parentNode) => + exportChildren( + parentNode, + textTransformersIndex, + textMatchTransformers, + unclosedTags, + // Add current unclosed tags to the list of unclosable tags - we don't want nested tags from + // textmatch transformers to close the outer ones, as that may result in invalid markdown. + // E.g. **text [text**](https://lexical.io) + // is invalid markdown, as the closing ** is inside the link. + // + [...unclosableTags, ...unclosedTags], + ), + (textNode, textContent) => + exportTextFormat( + textNode, + textContent, + textTransformersIndex, + unclosedTags, + unclosableTags, + ), ) if (result != null) { @@ -125,10 +153,26 @@ function exportChildren( if ($isLineBreakNode(child)) { output.push('\n') } else if ($isTextNode(child)) { - output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex)) + output.push( + exportTextFormat( + child, + child.getTextContent(), + textTransformersIndex, + unclosedTags, + unclosableTags, + ), + ) } else if ($isElementNode(child)) { // empty paragraph returns "" - output.push(exportChildren(child, textTransformersIndex, textMatchTransformers)) + output.push( + exportChildren( + child, + textTransformersIndex, + textMatchTransformers, + unclosedTags, + unclosableTags, + ), + ) } else if ($isDecoratorNode(child)) { output.push(child.getTextContent()) } @@ -141,6 +185,9 @@ function exportTextFormat( node: TextNode, textContent: string, textTransformers: Array, + // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats + unclosedTags: Array<{ format: TextFormatType; tag: string }>, + unclosableTags?: Array<{ format: TextFormatType; tag: string }>, ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" @@ -148,6 +195,14 @@ function exportTextFormat( // bring the whitespace back. So our returned string looks like this: " **foo** " const frozenString = textContent.trim() let output = frozenString + // the opening tags to be added to the result + let openingTags = '' + // the closing tags to be added to the result + let closingTagsBefore = '' + let closingTagsAfter = '' + + const prevNode = getTextSibling(node, true) + const nextNode = getTextSibling(node, false) const applied = new Set() @@ -155,27 +210,63 @@ function exportTextFormat( const format = transformer.format[0] const tag = transformer.tag + // dedup applied formats if (hasFormat(node, format) && !applied.has(format)) { // Multiple tags might be used for the same format (*, _) applied.add(format) - // Prevent adding opening tag is already opened by the previous sibling - const previousNode = getTextSibling(node, true) - if (!hasFormat(previousNode, format)) { - output = tag + output + // append the tag to openningTags, if it's not applied to the previous nodes, + // or the nodes before that (which would result in an unclosed tag) + if (!hasFormat(prevNode, format) || !unclosedTags.find((element) => element.tag === tag)) { + unclosedTags.push({ format, tag }) + openingTags += tag } + } + } + + // close any tags in the same order they were applied, if necessary + for (let i = 0; i < unclosedTags.length; i++) { + const nodeHasFormat = hasFormat(node, unclosedTags[i].format) + const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format) - // Prevent adding closing tag if next sibling will do it - const nextNode = getTextSibling(node, false) + // prevent adding closing tag if next sibling will do it + if (nodeHasFormat && nextNodeHasFormat) { + continue + } + + const unhandledUnclosedTags = [...unclosedTags] // Shallow copy to avoid modifying the original array + + while (unhandledUnclosedTags.length > i) { + const unclosedTag = unhandledUnclosedTags.pop() + + // If tag is unclosable, don't close it and leave it in the original array, + // So that it can be closed when it's no longer unclosable + if ( + unclosableTags && + unclosedTag && + unclosableTags.find((element) => element.tag === unclosedTag.tag) + ) { + continue + } - if (!hasFormat(nextNode, format)) { - output += tag + if (unclosedTag && typeof unclosedTag.tag === 'string') { + if (!nodeHasFormat) { + // Handles cases where the tag has not been closed before, e.g. if the previous node + // was a text match transformer that did not account for closing tags of the next node (e.g. a link) + closingTagsBefore += unclosedTag.tag + } else if (!nextNodeHasFormat) { + closingTagsAfter += unclosedTag.tag + } } + // Mutate the original array to remove the closed tag + unclosedTags.pop() } + break } + output = openingTags + output + closingTagsAfter // Replace trimmed version of textContent ensuring surrounding whitespace is not modified - return textContent.replace(frozenString, () => output) + return closingTagsBefore + textContent.replace(frozenString, () => output) } // Get next or previous text sibling a text node, including cases diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownImport.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownImport.ts index 05ac871366a..42a03d94fc8 100644 --- a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownImport.ts +++ b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownImport.ts @@ -7,7 +7,7 @@ */ import type { ListItemNode } from '@lexical/list' -import type { ElementNode, TextNode } from 'lexical' +import type { ElementNode } from 'lexical' import { $isListItemNode, $isListNode } from '@lexical/list' import { $isQuoteNode } from '@lexical/rich-text' @@ -30,9 +30,10 @@ import type { } from './MarkdownTransformers.js' import { IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI } from '../../../lexical/utils/environment.js' -import { isEmptyParagraph, PUNCTUATION_OR_SPACE, transformersByType } from './utils.js' +import { importTextTransformers } from './importTextTransformers.js' +import { isEmptyParagraph, transformersByType } from './utils.js' -type TextFormatTransformersIndex = Readonly<{ +export type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly> openTagsRegExp: RegExp transformersByTag: Readonly> @@ -209,7 +210,7 @@ function $importBlocks( } } - importTextFormatTransformers(textNode, textFormatTransformersIndex, textMatchTransformers) + importTextTransformers(textNode, textFormatTransformersIndex, textMatchTransformers) // If no transformer found and we left with original paragraph node // can check if its content can be appended to the previous node @@ -239,162 +240,6 @@ function $importBlocks( } } -// Processing text content and replaces text format tags. -// It takes outermost tag match and its content, creates text node with -// format based on tag and then recursively executed over node's content -// -// E.g. for "*Hello **world**!*" string it will create text node with -// "Hello **world**!" content and italic format and run recursively over -// its content to transform "**world**" part -function importTextFormatTransformers( - textNode: TextNode, - textFormatTransformersIndex: TextFormatTransformersIndex, - textMatchTransformers: Array, -) { - const textContent = textNode.getTextContent() - const match = findOutermostMatch(textContent, textFormatTransformersIndex) - - if (!match) { - // Once text format processing is done run text match transformers, as it - // only can span within single text node (unline formats that can cover multiple nodes) - importTextMatchTransformers(textNode, textMatchTransformers) - return - } - - let currentNode, leadingNode, remainderNode - - // If matching full content there's no need to run splitText and can reuse existing textNode - // to update its content and apply format. E.g. for **_Hello_** string after applying bold - // format (**) it will reuse the same text node to apply italic (_) - if (match[0] === textContent) { - currentNode = textNode - } else { - const startIndex = match.index || 0 - const endIndex = startIndex + match[0].length - - if (startIndex === 0) { - ;[currentNode, remainderNode] = textNode.splitText(endIndex) - } else { - ;[leadingNode, currentNode, remainderNode] = textNode.splitText(startIndex, endIndex) - } - } - - currentNode.setTextContent(match[2]) - const transformer = textFormatTransformersIndex.transformersByTag[match[1]] - - if (transformer) { - for (const format of transformer.format) { - if (!currentNode.hasFormat(format)) { - currentNode.toggleFormat(format) - } - } - } - - // Recursively run over inner text if it's not inline code - if (!currentNode.hasFormat('code')) { - importTextFormatTransformers(currentNode, textFormatTransformersIndex, textMatchTransformers) - } - - // Run over leading/remaining text if any - if (leadingNode) { - importTextFormatTransformers(leadingNode, textFormatTransformersIndex, textMatchTransformers) - } - - if (remainderNode) { - importTextFormatTransformers(remainderNode, textFormatTransformersIndex, textMatchTransformers) - } -} - -function importTextMatchTransformers( - textNode_: TextNode, - textMatchTransformers: Array, -) { - let textNode = textNode_ - - mainLoop: while (textNode) { - for (const transformer of textMatchTransformers) { - if (!transformer.replace || !transformer.importRegExp) { - continue - } - const match = textNode.getTextContent().match(transformer.importRegExp) - - if (!match) { - continue - } - - const startIndex = match.index || 0 - const endIndex = transformer.getEndIndex - ? transformer.getEndIndex(textNode, match) - : startIndex + match[0].length - - if (endIndex === false) { - continue - } - - let newTextNode, replaceNode - - if (startIndex === 0) { - ;[replaceNode, textNode] = textNode.splitText(endIndex) - } else { - ;[, replaceNode, newTextNode] = textNode.splitText(startIndex, endIndex) - } - - if (newTextNode) { - importTextMatchTransformers(newTextNode, textMatchTransformers) - } - transformer.replace(replaceNode, match) - continue mainLoop - } - - break - } -} - -// Finds first "content" match that is not nested into another tag -function findOutermostMatch( - textContent: string, - textTransformersIndex: TextFormatTransformersIndex, -): null | RegExpMatchArray { - const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp) - - if (openTagsMatch == null) { - return null - } - - for (const match of openTagsMatch) { - // Open tags reg exp might capture leading space so removing it - // before using match to find transformer - const tag = match.replace(/^\s/, '') - const fullMatchRegExp = textTransformersIndex.fullMatchRegExpByTag[tag] - if (fullMatchRegExp == null) { - continue - } - - const fullMatch = textContent.match(fullMatchRegExp) - const transformer = textTransformersIndex.transformersByTag[tag] - if (fullMatch != null && transformer != null) { - if (transformer.intraword !== false) { - return fullMatch - } - - // For non-intraword transformers checking if it's within a word - // or surrounded with space/punctuation/newline - const { index = 0 } = fullMatch - const beforeChar = textContent[index - 1] - const afterChar = textContent[index + fullMatch[0].length] - - if ( - (!beforeChar || PUNCTUATION_OR_SPACE.test(beforeChar)) && - (!afterChar || PUNCTUATION_OR_SPACE.test(afterChar)) - ) { - return fullMatch - } - } - } - - return null -} - function createTextFormatTransformersIndex( textTransformers: Array, ): TextFormatTransformersIndex { diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownTransformers.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownTransformers.ts index 626b78194cb..a7a04302f8e 100644 --- a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownTransformers.ts +++ b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownTransformers.ts @@ -172,8 +172,11 @@ export type TextMatchTransformer = Readonly<{ regExp: RegExp /** * Determines how the matched markdown text should be transformed into a node during the markdown import process + * + * @returns nothing, or a TextNode that may be a child of the new node that is created. + * If a TextNode is returned, text format matching will be applied to it (e.g. bold, italic, etc.) */ - replace?: (node: TextNode, match: RegExpMatchArray) => void + replace?: (node: TextNode, match: RegExpMatchArray) => TextNode | void /** * Single character that allows the transformer to trigger when typed in the editor. This does not affect markdown imports outside of the markdown shortcut plugin. * If the trigger is matched, the `regExp` will be used to match the text in the second step. diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/importTextFormatTransformer.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/importTextFormatTransformer.ts new file mode 100644 index 00000000000..d2cd3a32f0d --- /dev/null +++ b/packages/richtext-lexical/src/packages/@lexical/markdown/importTextFormatTransformer.ts @@ -0,0 +1,134 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { TextNode } from 'lexical' + +import type { TextFormatTransformersIndex } from './MarkdownImport.js' +import type { TextFormatTransformer } from './MarkdownTransformers.js' + +import { PUNCTUATION_OR_SPACE } from './utils.js' + +export function findOutermostTextFormatTransformer( + textNode: TextNode, + textFormatTransformersIndex: TextFormatTransformersIndex, +): { + endIndex: number + match: RegExpMatchArray + startIndex: number + transformer: TextFormatTransformer +} | null { + const textContent = textNode.getTextContent() + const match = findOutermostMatch(textContent, textFormatTransformersIndex) + + if (!match) { + return null + } + + const textFormatMatchStart: number = match.index || 0 + const textFormatMatchEnd = textFormatMatchStart + match[0].length + + const transformer: TextFormatTransformer = textFormatTransformersIndex.transformersByTag[match[1]] + + return { + endIndex: textFormatMatchEnd, + match, + startIndex: textFormatMatchStart, + transformer, + } +} + +// Finds first "content" match that is not nested into another tag +function findOutermostMatch( + textContent: string, + textTransformersIndex: TextFormatTransformersIndex, +): null | RegExpMatchArray { + const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp) + + if (openTagsMatch == null) { + return null + } + + for (const match of openTagsMatch) { + // Open tags reg exp might capture leading space so removing it + // before using match to find transformer + const tag = match.replace(/^\s/, '') + const fullMatchRegExp = textTransformersIndex.fullMatchRegExpByTag[tag] + if (fullMatchRegExp == null) { + continue + } + + const fullMatch = textContent.match(fullMatchRegExp) + const transformer = textTransformersIndex.transformersByTag[tag] + if (fullMatch != null && transformer != null) { + if (transformer.intraword !== false) { + return fullMatch + } + + // For non-intraword transformers checking if it's within a word + // or surrounded with space/punctuation/newline + const { index = 0 } = fullMatch + const beforeChar = textContent[index - 1] + const afterChar = textContent[index + fullMatch[0].length] + + if ( + (!beforeChar || PUNCTUATION_OR_SPACE.test(beforeChar)) && + (!afterChar || PUNCTUATION_OR_SPACE.test(afterChar)) + ) { + return fullMatch + } + } + } + + return null +} + +export function importTextFormatTransformer( + textNode: TextNode, + startIndex: number, + endIndex: number, + transformer: TextFormatTransformer, + match: RegExpMatchArray, +): { + nodeAfter: TextNode | undefined // If split + nodeBefore: TextNode | undefined // If split + transformedNode: TextNode +} { + const textContent = textNode.getTextContent() + + // No text matches - we can safely process the text format match + let nodeAfter, nodeBefore, transformedNode + + // If matching full content there's no need to run splitText and can reuse existing textNode + // to update its content and apply format. E.g. for **_Hello_** string after applying bold + // format (**) it will reuse the same text node to apply italic (_) + if (match[0] === textContent) { + transformedNode = textNode + } else { + if (startIndex === 0) { + ;[transformedNode, nodeAfter] = textNode.splitText(endIndex) + } else { + ;[nodeBefore, transformedNode, nodeAfter] = textNode.splitText(startIndex, endIndex) + } + } + + transformedNode.setTextContent(match[2]) + + if (transformer) { + for (const format of transformer.format) { + if (!transformedNode.hasFormat(format)) { + transformedNode.toggleFormat(format) + } + } + } + + return { + nodeAfter, + nodeBefore, + transformedNode, + } +} diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/importTextMatchTransformer.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/importTextMatchTransformer.ts new file mode 100644 index 00000000000..11a27d29d56 --- /dev/null +++ b/packages/richtext-lexical/src/packages/@lexical/markdown/importTextMatchTransformer.ts @@ -0,0 +1,105 @@ +import { type TextNode } from 'lexical' + +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { TextMatchTransformer } from './MarkdownTransformers.js' + +export function findOutermostTextMatchTransformer( + textNode_: TextNode, + textMatchTransformers: Array, +): { + endIndex: number + match: RegExpMatchArray + startIndex: number + transformer: TextMatchTransformer +} | null { + const textNode = textNode_ + + let foundMatchStartIndex: number | undefined = undefined + let foundMatchEndIndex: number | undefined = undefined + let foundMatchTransformer: TextMatchTransformer | undefined = undefined + let foundMatch: RegExpMatchArray | undefined = undefined + + for (const transformer of textMatchTransformers) { + if (!transformer.replace || !transformer.importRegExp) { + continue + } + const match = textNode.getTextContent().match(transformer.importRegExp) + + if (!match) { + continue + } + + const startIndex = match.index || 0 + const endIndex = transformer.getEndIndex + ? transformer.getEndIndex(textNode, match) + : startIndex + match[0].length + + if (endIndex === false) { + continue + } + + if ( + foundMatchStartIndex === undefined || + foundMatchEndIndex === undefined || + (startIndex < foundMatchStartIndex && endIndex > foundMatchEndIndex) + ) { + foundMatchStartIndex = startIndex + foundMatchEndIndex = endIndex + foundMatchTransformer = transformer + foundMatch = match + } + } + + if ( + foundMatchStartIndex === undefined || + foundMatchEndIndex === undefined || + foundMatchTransformer === undefined || + foundMatch === undefined + ) { + return null + } + + return { + endIndex: foundMatchEndIndex, + match: foundMatch, + startIndex: foundMatchStartIndex, + transformer: foundMatchTransformer, + } +} + +export function importFoundTextMatchTransformer( + textNode: TextNode, + startIndex: number, + endIndex: number, + transformer: TextMatchTransformer, + match: RegExpMatchArray, +): { + nodeAfter: TextNode | undefined // If split + nodeBefore: TextNode | undefined // If split + transformedNode?: TextNode +} | null { + let nodeAfter, nodeBefore, transformedNode + + if (startIndex === 0) { + ;[transformedNode, nodeAfter] = textNode.splitText(endIndex) + } else { + ;[nodeBefore, transformedNode, nodeAfter] = textNode.splitText(startIndex, endIndex) + } + + if (!transformer.replace) { + return null + } + const potentialTransformedNode = transformer.replace(transformedNode, match) + + return { + nodeAfter, + nodeBefore, + transformedNode: potentialTransformedNode || undefined, + } +} diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/importTextTransformers.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/importTextTransformers.ts new file mode 100644 index 00000000000..4d872a0707b --- /dev/null +++ b/packages/richtext-lexical/src/packages/@lexical/markdown/importTextTransformers.ts @@ -0,0 +1,120 @@ +import { $isTextNode, type TextNode } from 'lexical' + +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { TextFormatTransformersIndex } from './MarkdownImport.js' +import type { TextMatchTransformer } from './MarkdownTransformers.js' + +import { + findOutermostTextFormatTransformer, + importTextFormatTransformer, +} from './importTextFormatTransformer.js' +import { + findOutermostTextMatchTransformer, + importFoundTextMatchTransformer, +} from './importTextMatchTransformer.js' + +/** + * Handles applying both text format and text match transformers. + * It finds the outermost text format or text match and applies it, + * then recursively calls itself to apply the next outermost transformer, + * until there are no more transformers to apply. + */ +export function importTextTransformers( + textNode: TextNode, + textFormatTransformersIndex: TextFormatTransformersIndex, + textMatchTransformers: Array, +) { + let foundTextFormat = findOutermostTextFormatTransformer(textNode, textFormatTransformersIndex) + + let foundTextMatch = findOutermostTextMatchTransformer(textNode, textMatchTransformers) + + if (foundTextFormat && foundTextMatch) { + // Find the outermost transformer + if ( + foundTextFormat.startIndex <= foundTextMatch.startIndex && + foundTextFormat.endIndex >= foundTextMatch.endIndex + ) { + // foundTextFormat wraps foundTextMatch - apply foundTextFormat by setting foundTextMatch to null + foundTextMatch = null + } else { + // foundTextMatch wraps foundTextFormat - apply foundTextMatch by setting foundTextFormat to null + foundTextFormat = null + } + } + + if (foundTextFormat) { + const result = importTextFormatTransformer( + textNode, + foundTextFormat.startIndex, + foundTextFormat.endIndex, + foundTextFormat.transformer, + foundTextFormat.match, + ) + + if (result.nodeAfter && $isTextNode(result.nodeAfter) && !result.nodeAfter.hasFormat('code')) { + importTextTransformers(result.nodeAfter, textFormatTransformersIndex, textMatchTransformers) + } + if ( + result.nodeBefore && + $isTextNode(result.nodeBefore) && + !result.nodeBefore.hasFormat('code') + ) { + importTextTransformers(result.nodeBefore, textFormatTransformersIndex, textMatchTransformers) + } + if ( + result.transformedNode && + $isTextNode(result.transformedNode) && + !result.transformedNode.hasFormat('code') + ) { + importTextTransformers( + result.transformedNode, + textFormatTransformersIndex, + textMatchTransformers, + ) + } + return + } else if (foundTextMatch) { + const result = importFoundTextMatchTransformer( + textNode, + foundTextMatch.startIndex, + foundTextMatch.endIndex, + foundTextMatch.transformer, + foundTextMatch.match, + ) + if (!result) { + return + } + + if (result.nodeAfter && $isTextNode(result.nodeAfter) && !result.nodeAfter.hasFormat('code')) { + importTextTransformers(result.nodeAfter, textFormatTransformersIndex, textMatchTransformers) + } + if ( + result.nodeBefore && + $isTextNode(result.nodeBefore) && + !result.nodeBefore.hasFormat('code') + ) { + importTextTransformers(result.nodeBefore, textFormatTransformersIndex, textMatchTransformers) + } + if ( + result.transformedNode && + $isTextNode(result.transformedNode) && + !result.transformedNode.hasFormat('code') + ) { + importTextTransformers( + result.transformedNode, + textFormatTransformersIndex, + textMatchTransformers, + ) + } + return + } else { + // Done! + return + } +}