UNPKG

@lexical/markdown

Version:

This package contains Markdown helpers and functionality for Lexical.

648 lines (587 loc) • 19.4 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 {$sliceSelectedTextNodeContent} from '@lexical/selection'; import { $getRoot, $getState, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isTextNode, type BaseSelection, type ElementNode, type LexicalNode, type LineBreakNode, type TextFormatType, type TextNode, } from 'lexical'; import { type ElementTransformer, getCodeSpanDelimiter, hardLineBreakState, type MultilineElementTransformer, type TextFormatTransformer, type TextMatchTransformer, type Transformer, } from './MarkdownTransformers'; import {isEmptyParagraph, transformersByType} from './utils'; /** * Renders string from markdown. The selection is moved to the start after the operation. */ export function createMarkdownExport( transformers: Transformer[], shouldPreserveNewLines: boolean = false, ): (node?: ElementNode) => string { const byType = transformersByType(transformers); const elementTransformers = [...byType.multilineElement, ...byType.element]; const isNewlineDelimited = !shouldPreserveNewLines; // Export only uses text formats that are responsible for single format // e.g. it will filter out *** (bold, italic) and instead use separate ** and * const textFormatTransformers = byType.textFormat .filter(transformer => transformer.format.length === 1) // Make sure all text transformers that contain 'code' in their format are at the end of the array. Otherwise, formatted code like // <strong><code>code</code></strong> will be exported as `**Bold Code**`, as the code format will be applied first, and the bold format // will be applied second and thus skipped entirely, as the code format will prevent any further formatting. .sort((a, b) => { return ( Number(a.format.includes('code')) - Number(b.format.includes('code')) ); }); return node => { const output = []; const children = (node || $getRoot()).getChildren(); for (let i = 0; i < children.length; i++) { const child = children[i]; const result = $exportTopLevelElements( child, elementTransformers, textFormatTransformers, byType.textMatch, shouldPreserveNewLines, ); if (result != null) { output.push( // separate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"] isNewlineDelimited && i > 0 && !isEmptyParagraph(child) && !isEmptyParagraph(children[i - 1]) ? '\n'.concat(result) : result, ); } } // Ensure consecutive groups of texts are at least \n\n apart while each empty paragraph render as a newline. // Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld" return output.join('\n'); }; } /** * Creates a markdown export function that only exports selected content. * Uses a recursive structure similar to $appendNodesToHTML to support * extractWithChild for proper handling of partial selections within * inline elements like links. */ export function createSelectionMarkdownExport( transformers: Transformer[], shouldPreserveNewLines: boolean = false, ): (selection: BaseSelection) => string { const byType = transformersByType(transformers); const elementTransformers = [...byType.multilineElement, ...byType.element]; const isNewlineDelimited = !shouldPreserveNewLines; const textFormatTransformers = byType.textFormat .filter(transformer => transformer.format.length === 1) .sort((a, b) => { return ( Number(a.format.includes('code')) - Number(b.format.includes('code')) ); }); return selection => { const output = []; const children = $getRoot().getChildren(); for (let i = 0; i < children.length; i++) { const child = children[i]; const {shouldInclude, markdown} = $processNodeForSelection( child, selection, elementTransformers, textFormatTransformers, byType.textMatch, shouldPreserveNewLines, ); if (shouldInclude && markdown != null) { output.push( isNewlineDelimited && i > 0 && !isEmptyParagraph(child) && !isEmptyParagraph(children[i - 1]) ? '\n'.concat(markdown) : markdown, ); } } return output.join('\n'); }; } function $processNodeForSelection( node: LexicalNode, selection: BaseSelection, elementTransformers: (ElementTransformer | MultilineElementTransformer)[], textFormatTransformers: TextFormatTransformer[], textMatchTransformers: TextMatchTransformer[], shouldPreserveNewLines: boolean, ): {shouldInclude: boolean; markdown: string | null} { let shouldInclude = node.isSelected(selection); // For element transformers (heading, quote, list, code block, etc.) for (const transformer of elementTransformers) { if (!transformer.export) { continue; } const result = transformer.export( node, node_ => $exportChildrenForSelection( node_, selection, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines, ).markdown, selection, ); if (result != null) { if (!shouldInclude) { // Check if any descendant is selected if ($isElementNode(node)) { const childResult = $exportChildrenForSelection( node, selection, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines, ); if (childResult.shouldInclude) { shouldInclude = true; } } } return {markdown: result, shouldInclude}; } } if ($isElementNode(node)) { const childResult = $exportChildrenForSelection( node, selection, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines, ); return { markdown: childResult.markdown, shouldInclude: shouldInclude || childResult.shouldInclude, }; } else if ($isDecoratorNode(node)) { return {markdown: node.getTextContent(), shouldInclude}; } else { return {markdown: null, shouldInclude}; } } function $exportChildrenForSelection( node: ElementNode, selection: BaseSelection, textFormatTransformers: TextFormatTransformer[], textMatchTransformers: TextMatchTransformer[], shouldPreserveNewLines: boolean, unclosedTags?: {format: TextFormatType; tag: string}[], unclosableTags?: {format: TextFormatType; tag: string}[], ): {shouldInclude: boolean; markdown: string} { const output = []; const children = node.getChildren(); let anyChildIncluded = false; if (!unclosedTags) { unclosedTags = []; } if (!unclosableTags) { unclosableTags = []; } mainLoop: for (const child of children) { let childIncluded = child.isSelected(selection); // Try text match transformers (links, etc.) for (const transformer of textMatchTransformers) { if (!transformer.export) { continue; } const result = transformer.export( child, parentNode => $exportChildrenForSelection( parentNode, selection, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines, unclosedTags, [...unclosableTags, ...unclosedTags], ).markdown, (textNode, textContent) => { const slicedNode = $sliceSelectedTextNodeContent( selection, textNode, 'clone', ); return exportTextFormat( textNode, slicedNode.getTextContent(), textFormatTransformers, unclosedTags, unclosableTags, shouldPreserveNewLines, ); }, ); if (result != null) { // Check extractWithChild if this node wasn't directly selected if ( !childIncluded && $isElementNode(child) && child.getChildren().some(c => c.isSelected(selection)) && child.extractWithChild(child, selection, 'html') ) { childIncluded = true; } if (childIncluded) { output.push(result); anyChildIncluded = true; } continue mainLoop; } } if ($isLineBreakNode(child)) { if (childIncluded) { output.push($exportLineBreak(child)); anyChildIncluded = true; } } else if ($isTextNode(child)) { if (childIncluded) { const target = $sliceSelectedTextNodeContent(selection, child, 'clone'); output.push( exportTextFormat( child, target.getTextContent(), textFormatTransformers, unclosedTags, unclosableTags, shouldPreserveNewLines, ), ); anyChildIncluded = true; } } else if ($isElementNode(child)) { const childResult = $exportChildrenForSelection( child, selection, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines, unclosedTags, unclosableTags, ); // extractWithChild: if child has selected descendants, ask parent if it should be included if ( !childIncluded && childResult.shouldInclude && child.extractWithChild(child, selection, 'html') ) { childIncluded = true; } if (childIncluded || childResult.shouldInclude) { output.push(childResult.markdown); anyChildIncluded = true; } } else if ($isDecoratorNode(child)) { if (childIncluded) { output.push(child.getTextContent()); anyChildIncluded = true; } } } return {markdown: output.join(''), shouldInclude: anyChildIncluded}; } function $exportTopLevelElements( node: LexicalNode, elementTransformers: (ElementTransformer | MultilineElementTransformer)[], textTransformersIndex: TextFormatTransformer[], textMatchTransformers: TextMatchTransformer[], shouldPreserveNewLines: boolean, ): string | null { for (const transformer of elementTransformers) { if (!transformer.export) { continue; } const result = transformer.export(node, _node => $exportChildren( _node, textTransformersIndex, textMatchTransformers, undefined, undefined, shouldPreserveNewLines, ), ); if (result != null) { return result; } } if ($isElementNode(node)) { return $exportChildren( node, textTransformersIndex, textMatchTransformers, undefined, undefined, shouldPreserveNewLines, ); } else if ($isDecoratorNode(node)) { return node.getTextContent(); } else { return null; } } function $exportChildren( node: ElementNode, textTransformersIndex: TextFormatTransformer[], textMatchTransformers: TextMatchTransformer[], unclosedTags?: {format: TextFormatType; tag: string}[], unclosableTags?: {format: TextFormatType; tag: string}[], shouldPreserveNewLines: boolean = false, ): 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) { if (!transformer.export) { continue; } const result = transformer.export( child, 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], shouldPreserveNewLines, ), (textNode, textContent) => exportTextFormat( textNode, textContent, textTransformersIndex, unclosedTags, unclosableTags, shouldPreserveNewLines, ), ); if (result != null) { output.push(result); continue mainLoop; } } if ($isLineBreakNode(child)) { output.push($exportLineBreak(child)); } else if ($isTextNode(child)) { output.push( exportTextFormat( child, child.getTextContent(), textTransformersIndex, unclosedTags, unclosableTags, shouldPreserveNewLines, ), ); } else if ($isElementNode(child)) { // empty paragraph returns "" output.push( $exportChildren( child, textTransformersIndex, textMatchTransformers, unclosedTags, unclosableTags, shouldPreserveNewLines, ), ); } else if ($isDecoratorNode(child)) { output.push(child.getTextContent()); } } return output.join(''); } function $exportLineBreak(node: LineBreakNode): string { return $getState(node, hardLineBreakState) + '\n'; } function exportTextFormat( node: TextNode, textContent: string, textTransformers: TextFormatTransformer[], // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats unclosedTags: {format: TextFormatType; tag: string}[], unclosableTags?: {format: TextFormatType; tag: string}[], shouldPreserveNewLines: boolean = false, ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" // If the node has no format, we use the original text. // Otherwise, we escape leading and trailing whitespaces to their corresponding code points, // ensuring the returned string maintains its original formatting, e.g., "**&#32;&#32;&#32;foo&#32;&#32;&#32;**". const isCode = node.hasFormat('code'); let output = textContent; if (!isCode) { // Preserve literal backslashes when preserving source newlines. output = shouldPreserveNewLines ? output.replace(/([*_`~])/g, '\\$1') : output.replace(/([*_`~\\])/g, '\\$1'); } let leadingSpace: string; let trimmedOutput: string; let trailingSpace: string; let isWhitespaceOnly: boolean; if (isCode) { // Inline code is an atomic literal span with a content-derived fence, so // its whitespace stays inside the fence and other formats wrap around it. const {fence, padded} = getCodeSpanDelimiter(textContent); leadingSpace = ''; trailingSpace = ''; trimmedOutput = fence + padded + fence; isWhitespaceOnly = false; } else { // Extract leading and trailing whitespaces. // CommonMark flanking rules require formatting tags to be adjacent to non-whitespace characters. const match = output.match(/^(\s*)(.*?)(\s*)$/s) || ['', '', output, '']; leadingSpace = match[1]; trimmedOutput = match[2]; trailingSpace = match[3]; isWhitespaceOnly = trimmedOutput === ''; } // 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(); for (const transformer of textTransformers) { const format = transformer.format[0]; const tag = transformer.tag; // Inline code uses a content-derived fence handled above, not a static tag. if (format === 'code') { continue; } // dedup applied formats if (checkHasFormat(node, format) && !applied.has(format)) { applied.add(format); // append the tag to openingTags, if it's not applied to the previous nodes, // or the nodes before that (which would result in an unclosed tag) if ( !checkHasFormat(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 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 (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; } // If the node is entirely whitespace, we don't apply opening/closing tags around it. // However, it must still output closing tags from previous nodes. if (isWhitespaceOnly && !node.hasFormat('code')) { return closingTagsBefore + output; } // Flanking Compliance: Notice how openingTags and closingTagsAfter are placed INSIDE the whitespace boundaries! return ( closingTagsBefore + leadingSpace + openingTags + trimmedOutput + closingTagsAfter + trailingSpace ); } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { const sibling = backward ? node.getPreviousSibling() : node.getNextSibling(); if ($isTextNode(sibling)) { return sibling; } return null; } function hasFormat( node: LexicalNode | null | undefined, format: TextFormatType, ): boolean { return $isTextNode(node) && node.hasFormat(format); } function checkHasFormat(n: TextNode | null, f: TextFormatType): boolean { if (!hasFormat(n, f)) { return false; } if (f === 'code') { return true; } if (n && /^\s*$/.test(n.getTextContent())) { return false; } return true; }