@lexical/markdown
Version:
This package contains Markdown helpers and functionality for Lexical.
347 lines (309 loc) • 11 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 {$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,
};
}