@terrible-lexical/markdown
Version:
This package contains Markdown helpers and functionality for Lexical.
223 lines (187 loc) • 5.95 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 type {
ElementTransformer,
TextFormatTransformer,
TextMatchTransformer,
Transformer,
} from '@terrible-lexical/markdown/src';
import type {ElementNode, LexicalNode, TextFormatType, TextNode} from 'terrible-lexical';
import {
$getRoot,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isTextNode,
} from 'terrible-lexical';
import {transformersByType} from './utils';
export function createMarkdownExport(
transformers: Array<Transformer>,
): (node?: ElementNode) => string {
const byType = transformersByType(transformers);
// 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,
);
return (node) => {
const output = [];
const children = (node || $getRoot()).getChildren();
for (const child of children) {
const result = exportTopLevelElements(
child,
byType.element,
textFormatTransformers,
byType.textMatch,
);
if (result != null) {
output.push(result);
}
}
return output.join('\n\n');
};
}
function exportTopLevelElements(
node: LexicalNode,
elementTransformers: Array<ElementTransformer>,
textTransformersIndex: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
): string | null {
for (const transformer of elementTransformers) {
const result = transformer.export(node, (_node) =>
exportChildren(_node, textTransformersIndex, textMatchTransformers),
);
if (result != null) {
return result;
}
}
if ($isElementNode(node)) {
return exportChildren(node, textTransformersIndex, textMatchTransformers);
} else if ($isDecoratorNode(node)) {
return node.getTextContent();
} else {
return null;
}
}
function exportChildren(
node: ElementNode,
textTransformersIndex: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
): string {
const output = [];
const children = node.getChildren();
mainLoop: for (const child of children) {
for (const transformer of textMatchTransformers) {
const result = transformer.export(
child,
(parentNode) =>
exportChildren(
parentNode,
textTransformersIndex,
textMatchTransformers,
),
(textNode, textContent) =>
exportTextFormat(textNode, textContent, textTransformersIndex),
);
if (result != null) {
output.push(result);
continue mainLoop;
}
}
if ($isLineBreakNode(child)) {
output.push('\n');
} else if ($isTextNode(child)) {
output.push(
exportTextFormat(child, child.getTextContent(), textTransformersIndex),
);
} else if ($isElementNode(child)) {
output.push(
exportChildren(child, textTransformersIndex, textMatchTransformers),
);
} else if ($isDecoratorNode(child)) {
output.push(child.getTextContent());
}
}
return output.join('');
}
function exportTextFormat(
node: TextNode,
textContent: string,
textTransformers: Array<TextFormatTransformer>,
): string {
// This function handles the case of a string looking like this: " foo "
// Where it would be invalid markdown to generate: "** foo **"
// We instead want to trim the whitespace out, apply formatting, and then
// bring the whitespace back. So our returned string looks like this: " **foo** "
const frozenString = textContent.trim();
let output = frozenString;
const applied = new Set();
for (const transformer of textTransformers) {
const format = transformer.format[0];
const tag = transformer.tag;
if (hasFormat(node, format) && !applied.has(format)) {
// Multiple tags might be used for the same format (*, _)
applied.add(format);
// Prevent adding opening tag is already opened by the previous sibling
const previousNode = getTextSibling(node, true);
if (!hasFormat(previousNode, format)) {
output = tag + output;
}
// Prevent adding closing tag if next sibling will do it
const nextNode = getTextSibling(node, false);
if (!hasFormat(nextNode, format)) {
output += tag;
}
}
}
// Replace trimmed version of textContent ensuring surrounding whitespace is not modified
return textContent.replace(frozenString, output);
}
// Get next or previous text sibling a text node, including cases
// when it's a child of inline element (e.g. link)
function getTextSibling(node: TextNode, backward: boolean): TextNode | null {
let sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
if (!sibling) {
const parent = node.getParentOrThrow();
if (parent.isInline()) {
sibling = backward
? parent.getPreviousSibling()
: parent.getNextSibling();
}
}
while (sibling) {
if ($isElementNode(sibling)) {
if (!sibling.isInline()) {
break;
}
const descendant = backward
? sibling.getLastDescendant()
: sibling.getFirstDescendant();
if ($isTextNode(descendant)) {
return descendant;
} else {
sibling = backward
? sibling.getPreviousSibling()
: sibling.getNextSibling();
}
}
if ($isTextNode(sibling)) {
return sibling;
}
if (!$isElementNode(sibling)) {
return null;
}
}
return null;
}
function hasFormat(
node: LexicalNode | null | undefined,
format: TextFormatType,
): boolean {
return $isTextNode(node) && node.hasFormat(format);
}