@lexical/markdown
Version:
This package contains Markdown helpers and functionality for Lexical.
648 lines (587 loc) • 19.4 kB
text/typescript
/**
* 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., "**   foo   **".
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;
}