UNPKG

@lexical/markdown

Version:

This package contains Markdown helpers and functionality for Lexical.

347 lines (309 loc) 11 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 {$isListItemNode, $isListNode, type ListItemNode} from '@lexical/list'; import {$isQuoteNode} from '@lexical/rich-text'; import { $createParagraphNode, $createTabNode, $createTextNode, $findMatchingParent, $isElementNode, $isParagraphNode, type ElementNode, type TextNode, } from 'lexical'; import {importTextTransformers} from './importTextTransformers'; import { $createMarkdownLineBreakNode, type ElementTransformer, type MultilineElementTransformer, type TextFormatTransformer, type TextMatchTransformer, type Transformer, } from './MarkdownTransformers'; import {isEmptyParagraph, transformersByType} from './utils'; export type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly<Record<string, RegExp>>; openTagsRegExp: RegExp; transformersByTag: Readonly<Record<string, TextFormatTransformer>>; }>; /** * Parses a markdown string and appends the resulting nodes to `container`. * Does not clear the container or touch the selection — callers handle that. */ export function $importMarkdownNodes( markdownString: string, container: ElementNode, transformers: Transformer[], shouldPreserveNewLines = false, ): void { const byType = transformersByType(transformers); const textFormatTransformersIndex = createTextFormatTransformersIndex( byType.textFormat, ); const lines = markdownString.split('\n'); const linesLength = lines.length; for (let i = 0; i < linesLength; i++) { const lineText = lines[i]; const [imported, shiftedIndex] = $importMultiline( lines, i, byType.multilineElement, container, ); if (imported) { i = shiftedIndex; continue; } $importBlocks( lineText, container, byType.element, textFormatTransformersIndex, byType.textMatch, shouldPreserveNewLines, ); } const children = container.getChildren(); for (const child of children) { if ( !shouldPreserveNewLines && isEmptyParagraph(child) && container.getChildrenSize() > 1 ) { child.remove(); continue; } if ($isElementNode(child)) { for (const textNode of child.getAllTextNodes()) { $normalizeMarkdownTextNode(textNode); } } } } /** * * @returns first element of the returned tuple is a boolean indicating if a multiline element was imported. The second element is the index of the last line that was processed. */ function $importMultiline( lines: string[], startLineIndex: number, multilineElementTransformers: MultilineElementTransformer[], rootNode: ElementNode, ): [boolean, number] { for (const transformer of multilineElementTransformers) { const {handleImportAfterStartMatch, regExpEnd, regExpStart, replace} = transformer; const startMatch = lines[startLineIndex].match(regExpStart); if (!startMatch) { continue; // Try next transformer } if (handleImportAfterStartMatch) { const result = handleImportAfterStartMatch({ lines, rootNode, startLineIndex, startMatch, transformer, }); if (result === null) { continue; } else if (result) { return result; } } const regexpEndRegex: RegExp | undefined = typeof regExpEnd === 'object' && 'regExp' in regExpEnd ? regExpEnd.regExp : regExpEnd; const isEndOptional = regExpEnd && typeof regExpEnd === 'object' && 'optional' in regExpEnd ? regExpEnd.optional : !regExpEnd; let endLineIndex = startLineIndex; const linesLength = lines.length; // check every single line for the closing match. It could also be on the same line as the opening match. while (endLineIndex < linesLength) { const endMatch = regexpEndRegex ? lines[endLineIndex].match(regexpEndRegex) : null; if (!endMatch) { if ( !isEndOptional || (isEndOptional && endLineIndex < linesLength - 1) // Optional end, but didn't reach the end of the document yet => continue searching for potential closing match ) { endLineIndex++; continue; // Search next line for closing match } } // Now, check if the closing match matched is the same as the opening match. // If it is, we need to continue searching for the actual closing match. if ( endMatch && startLineIndex === endLineIndex && endMatch.index === startMatch.index ) { endLineIndex++; continue; // Search next line for closing match } // At this point, we have found the closing match. Next: calculate the lines in between open and closing match // This should not include the matches themselves, and be split up by lines const linesInBetween = []; if (endMatch && startLineIndex === endLineIndex) { linesInBetween.push( lines[startLineIndex].slice( startMatch[0].length, -endMatch[0].length, ), ); } else { for (let i = startLineIndex; i <= endLineIndex; i++) { if (i === startLineIndex) { const text = lines[i].slice(startMatch[0].length); linesInBetween.push(text); // Also include empty text } else if (i === endLineIndex && endMatch) { const text = lines[i].slice(0, -endMatch[0].length); linesInBetween.push(text); // Also include empty text } else { linesInBetween.push(lines[i]); } } } if ( replace(rootNode, null, startMatch, endMatch, linesInBetween, true) !== false ) { // Return here. This $importMultiline function is run line by line and should only process a single multiline element at a time. return [true, endLineIndex]; } // The replace function returned false, despite finding the matching open and close tags => this transformer does not want to handle it. // Thus, we continue letting the remaining transformers handle the passed lines of text from the beginning break; } } // No multiline transformer handled this line successfully return [false, startLineIndex]; } function $importBlocks( lineText: string, rootNode: ElementNode, elementTransformers: ElementTransformer[], textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: TextMatchTransformer[], shouldPreserveNewLines: boolean, ) { const textNode = $createTextNode(lineText); 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)); if (replace(elementNode, [textNode], match, true) !== false) { break; } } } 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 // if it's a paragraph, quote or list if (elementNode.getParent() !== null && lineText.length > 0) { const previousNode = elementNode.getPreviousSibling(); if ( !shouldPreserveNewLines && // Only append if we're not preserving newlines ($isParagraphNode(previousNode) || $isQuoteNode(previousNode) || $isListNode(previousNode)) ) { let targetNode: typeof previousNode | ListItemNode | 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, [ $createMarkdownLineBreakNode(targetNode), ...elementNode.getChildren(), ]); elementNode.remove(); } } } } // Look in node for '\t' and create a TabNode for each occurrence. function $normalizeMarkdownTextNode(textNode: TextNode): void { const tabOffsets: Set<number> = new Set(); const text = textNode.getTextContent(); let index = text.indexOf('\t'); // Find all tab occurrences while (index !== -1) { tabOffsets.add(index); tabOffsets.add(index + 1); index = text.indexOf('\t', index + 1); } // Split node to isolate each tab then replace '\t' into TabNode const splitNodes = textNode.splitText(...tabOffsets); splitNodes.forEach(node => { if (node.getTextContent() === '\t') { node.replace($createTabNode()); } }); } function createTextFormatTransformersIndex( textTransformers: TextFormatTransformer[], ): TextFormatTransformersIndex { const transformersByTag: Record<string, TextFormatTransformer> = {}; const fullMatchRegExpByTag: Record<string, RegExp> = {}; const openTagsRegExp: string[] = []; for (const transformer of textTransformers) { const {tag} = transformer; transformersByTag[tag] = transformer; const tagRegExp = tag.replace(/(\*|\^|\+)/g, '\\$1'); openTagsRegExp.push(tagRegExp); // Single-char tag (e.g. "*") if (tag.length === 1) { if (tag === '`') { // Capture the preceding character in group 1 (empty string at start-of-string // via the ^ branch) rather than using a negative lookbehind, which is not // supported in Safari < 16.4. Consumers must add match[1].length to // match.index to find the real start of the span (see importTextFormatTransformer.ts). fullMatchRegExpByTag[tag] = new RegExp( `(^|[^\\\\\`])(\`)((?:\\\\\`|[^\`])+?)(\`)(?!\`)`, ); } else { fullMatchRegExpByTag[tag] = new RegExp( `(^|[^\\\\${tagRegExp}])(${tagRegExp})((\\\\${tagRegExp})?.*?[^${tagRegExp}\\s](\\\\${tagRegExp})?)(${tagRegExp})(?![\\\\${tagRegExp}])`, ); } } else { // Multi-char tags (e.g. "**") fullMatchRegExpByTag[tag] = new RegExp( `(^|[^\\\\])(${tagRegExp})((\\\\${tagRegExp})?.*?[^\\s](\\\\${tagRegExp})?)(${tagRegExp})(?!\\\\)`, ); } } return { // Reg exp to find open tag + content + close tag fullMatchRegExpByTag, // Regexp to locate *any* potential opening tag (longest first). // The former (?<!\\) escape guard has been removed — the delimiter // scanner's isEscaped() check handles escape filtering at match time. openTagsRegExp: new RegExp(`(${openTagsRegExp.join('|')})`, 'g'), transformersByTag, }; }