UNPKG

@terrible-lexical/markdown

Version:

This package contains Markdown helpers and functionality for Lexical.

404 lines (351 loc) 12.1 kB
/** * 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 {CodeNode} from '@terrible-lexical/code/src'; import type { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer, } from '@terrible-lexical/markdown/src'; import type {LexicalNode, TextNode} from 'terrible-lexical'; import {$createCodeNode} from '@terrible-lexical/code/src'; import {$isListItemNode, $isListNode} from '@terrible-lexical/list/src'; import {$isQuoteNode} from '@terrible-lexical/rich-text/src'; import {$findMatchingParent} from '@terrible-lexical/utils/src'; import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isParagraphNode, $isTextNode, ElementNode, } from 'terrible-lexical'; import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from '@terrible-lexical/shared/src/environment'; import {PUNCTUATION_OR_SPACE, transformersByType} from './utils'; const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/; const CODE_BLOCK_REG_EXP = /^```(\w{1,10})?\s?$/; type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly<Record<string, RegExp>>; openTagsRegExp: RegExp; transformersByTag: Readonly<Record<string, TextFormatTransformer>>; }>; export function createMarkdownImport( transformers: Array<Transformer>, ): (markdownString: string, node?: ElementNode) => void { const byType = transformersByType(transformers); const textFormatTransformersIndex = createTextFormatTransformersIndex( byType.textFormat, ); return (markdownString, node) => { const lines = markdownString.split('\n'); const linesLength = lines.length; const root = node || $getRoot(); root.clear(); for (let i = 0; i < linesLength; i++) { const lineText = lines[i]; // Codeblocks are processed first as anything inside such block // is ignored for further processing // TODO: // Abstract it to be dynamic as other transformers (add multiline match option) const [codeBlockNode, shiftedIndex] = importCodeBlock(lines, i, root); if (codeBlockNode != null) { i = shiftedIndex; continue; } importBlocks( lineText, root, byType.element, textFormatTransformersIndex, byType.textMatch, ); } // Removing empty paragraphs as md does not really // allow empty lines and uses them as dilimiter const children = root.getChildren(); for (const child of children) { if (isEmptyParagraph(child)) { child.remove(); } } if ($getSelection() !== null) { root.selectEnd(); } }; } function isEmptyParagraph(node: LexicalNode): boolean { if (!$isParagraphNode(node)) { return false; } const firstChild = node.getFirstChild(); return ( firstChild == null || (node.getChildrenSize() === 1 && $isTextNode(firstChild) && MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent())) ); } function importBlocks( lineText: string, rootNode: ElementNode, elementTransformers: Array<ElementTransformer>, textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: Array<TextMatchTransformer>, ) { const lineTextTrimmed = lineText.trim(); const textNode = $createTextNode(lineTextTrimmed); const elementNode = $createParagraphNode(); elementNode.append(textNode); rootNode.append(elementNode); for (const {regExp, replace} of elementTransformers) { const match = lineText.match(regExp); if (match) { textNode.setTextContent(lineText.slice(match[0].length)); replace(elementNode, [textNode], match, true); break; } } importTextFormatTransformers( 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 // if it's a paragraph, quote or list if (elementNode.isAttached() && lineTextTrimmed.length > 0) { const previousNode = elementNode.getPreviousSibling(); if ( $isParagraphNode(previousNode) || $isQuoteNode(previousNode) || $isListNode(previousNode) ) { let targetNode: LexicalNode | null = previousNode; if ($isListNode(previousNode)) { const lastDescendant = previousNode.getLastDescendant(); if (lastDescendant == null) { targetNode = null; } else { targetNode = $findMatchingParent(lastDescendant, $isListItemNode); } } if (targetNode != null && targetNode.getTextContentSize() > 0) { targetNode.splice(targetNode.getChildrenSize(), 0, [ $createLineBreakNode(), ...elementNode.getChildren(), ]); elementNode.remove(); } } } } function importCodeBlock( lines: Array<string>, startLineIndex: number, rootNode: ElementNode, ): [CodeNode | null, number] { const openMatch = lines[startLineIndex].match(CODE_BLOCK_REG_EXP); if (openMatch) { let endLineIndex = startLineIndex; const linesLength = lines.length; while (++endLineIndex < linesLength) { const closeMatch = lines[endLineIndex].match(CODE_BLOCK_REG_EXP); if (closeMatch) { const codeBlockNode = $createCodeNode(openMatch[1]); const textNode = $createTextNode( lines.slice(startLineIndex + 1, endLineIndex).join('\n'), ); codeBlockNode.append(textNode); rootNode.append(codeBlockNode); return [codeBlockNode, endLineIndex]; } } } return [null, startLineIndex]; } // 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<TextMatchTransformer>, ) { 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, remainderNode, leadingNode; // 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<TextMatchTransformer>, ) { let textNode = textNode_; mainLoop: while (textNode) { for (const transformer of textMatchTransformers) { const match = textNode.getTextContent().match(transformer.importRegExp); if (!match) { continue; } const startIndex = match.index || 0; const endIndex = startIndex + match[0].length; let replaceNode, leftTextNode, rightTextNode; if (startIndex === 0) { [replaceNode, textNode] = textNode.splitText(endIndex); } else { [leftTextNode, replaceNode, rightTextNode] = textNode.splitText( startIndex, endIndex, ); } if (leftTextNode) { importTextMatchTransformers(leftTextNode, textMatchTransformers); } if (rightTextNode) { textNode = rightTextNode; } transformer.replace(replaceNode, match); continue mainLoop; } break; } } // Finds first "<tag>content<tag>" match that is not nested into another tag function findOutermostMatch( textContent: string, textTransformersIndex: TextFormatTransformersIndex, ): RegExpMatchArray | null { 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<TextFormatTransformer>, ): TextFormatTransformersIndex { const transformersByTag: Record<string, TextFormatTransformer> = {}; const fullMatchRegExpByTag: Record<string, RegExp> = {}; const openTagsRegExp = []; const escapeRegExp = `(?<![\\\\])`; for (const transformer of textTransformers) { const {tag} = transformer; transformersByTag[tag] = transformer; const tagRegExp = tag.replace(/(\*|\^|\+)/g, '\\$1'); openTagsRegExp.push(tagRegExp); if (IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT) { fullMatchRegExpByTag[tag] = new RegExp( `(${tagRegExp})(?![${tagRegExp}\\s])(.*?[^${tagRegExp}\\s])${tagRegExp}(?!${tagRegExp})`, ); } else { fullMatchRegExpByTag[tag] = new RegExp( `(?<![\\\\${tagRegExp}])(${tagRegExp})((\\\\${tagRegExp})?.*?[^${tagRegExp}\\s](\\\\${tagRegExp})?)((?<!\\\\)|(?<=\\\\\\\\))(${tagRegExp})(?![\\\\${tagRegExp}])`, ); } } return { // Reg exp to find open tag + content + close tag fullMatchRegExpByTag, // Reg exp to find opening tags openTagsRegExp: new RegExp( (IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT ? '' : `${escapeRegExp}`) + '(' + openTagsRegExp.join('|') + ')', 'g', ), transformersByTag, }; }