@lexical/markdown
Version:
This package contains Markdown helpers and functionality for Lexical.
1,352 lines (1,311 loc) • 86.6 kB
JavaScript
/**
* 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.
*
*/
'use strict';
var lexical = require('lexical');
var selection = require('@lexical/selection');
var codeCore = require('@lexical/code-core');
var link = require('@lexical/link');
var list = require('@lexical/list');
var richText = require('@lexical/rich-text');
/**
* 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.
*
*/
function indexBy(list, callback) {
const index = {};
for (const item of list) {
const key = callback(item);
if (!key) {
continue;
}
if (index[key]) {
index[key].push(item);
} else {
index[key] = [item];
}
}
return index;
}
function transformersByType(transformers) {
const byType = indexBy(transformers, t => t.type);
return {
element: byType.element || [],
multilineElement: byType['multiline-element'] || [],
textFormat: byType['text-format'] || [],
textMatch: byType['text-match'] || []
};
}
const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/;
const WHITESPACE = /\s/;
const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~]/;
const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/;
function isEmptyParagraph(node) {
if (!lexical.$isParagraphNode(node)) {
return false;
}
const firstChild = node.getFirstChild();
return firstChild == null || node.getChildrenSize() === 1 && lexical.$isTextNode(firstChild) && MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent());
}
function unescapeText(value) {
return value.replace(/\\([!-/:-@[-`{-~])/g, '$1').replace(/&#(\d+);/g, (_, codePoint) => String.fromCodePoint(Number(codePoint)));
}
/**
* 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.
*
*/
const ORDERED_LIST_REGEX = /^(\s*)(\d{1,})\.\s/;
const UNORDERED_LIST_REGEX = /^(\s*)[-*+]\s/;
const CHECK_LIST_REGEX = /^(\s*)(?:[-*+]\s)?\s?(\[(\s|x)?\])\s/i;
const HEADING_REGEX = /^(#{1,6})\s/;
const QUOTE_REGEX = /^>\s/;
const CODE_START_REGEX = /^([ \t]*`{3,})([\w-]+)?[ \t]?/;
const CODE_END_REGEX = /^[ \t]*`{3,}$/;
const CODE_SINGLE_LINE_REGEX = /^[ \t]*```[^`]+(?:(?:`{1,2}|`{4,})[^`]+)*```(?:[^`]|$)/;
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
/**
* Whether `line` is a Markdown table delimiter row such as `| --- | :--: |`.
*
* This is the linear-time equivalent of `/^(\| ?:?-*:? ?)+\|\s?$/`. That
* pattern nests `-*` inside a `(...)+` group, a shape that backtracking regexp
* engines (e.g. Safari/JavaScriptCore) may run in super-linear time. A manual
* scan is guaranteed O(n).
*/
function isTableRowDivider(line) {
// Must start with a leading pipe.
if (line[0] !== '|') {
return false;
}
const {
length
} = line;
let i = 1;
let cells = 0;
// Each iteration consumes one ` ?:?-*:? ?\|` cell-and-pipe unit. Cell
// characters (space, colon, dash) are disjoint from the `|` delimiter, so a
// greedy scan never needs to backtrack.
while (i < length) {
let j = i;
if (line[j] === ' ') {
j++;
}
if (line[j] === ':') {
j++;
}
while (line[j] === '-') {
j++;
}
if (line[j] === ':') {
j++;
}
if (line[j] === ' ') {
j++;
}
if (line[j] !== '|') {
break;
}
cells++;
i = j + 1;
}
// Require at least one cell, then an optional single trailing whitespace
// character (`\s?`) before the end of the line (`$`).
return cells > 0 && (i === length || i === length - 1 && /\s/.test(line[i]));
}
const TAG_START_REGEX = /^<[a-z_][\w-]*(?:\s[^<>]*)?\/?>/i;
const TAG_END_REGEX = /^<\/[a-z_][\w-]*\s*>/i;
const ENDS_WITH = regex => new RegExp(`(?:${regex.source})$`, regex.flags);
const listMarkerState = /* @__PURE__ */lexical.createState('mdListMarker', {
parse: v => typeof v === 'string' && /^[-*+]$/.test(v) ? v : '-',
resetOnCopyNode: true
});
const codeFenceState = /* @__PURE__ */lexical.createState('mdCodeFence', {
parse: val => {
if (typeof val === 'string' && /^`{3,}$/.test(val)) {
return val;
}
return '```';
},
resetOnCopyNode: true
});
const hardLineBreakState = /* @__PURE__ */lexical.createState('mdHardLineBreak', {
parse: val => {
if (typeof val === 'string' && /^(\\| {2,})$/.test(val)) {
return val;
}
return '';
},
resetOnCopyNode: true
});
function parseMarkdownHardLineBreak(line) {
if (line.endsWith('\\')) {
return [line.slice(0, -1), '\\'];
}
const spaces = line.match(/^(.*?\S)( {2,})$/);
return spaces ? [spaces[1], spaces[2]] : null;
}
function hasNonWhitespaceContentOnLine(children, endIndex) {
for (let i = endIndex - 1; i >= 0; i--) {
if (lexical.$isLineBreakNode(children[i])) {
return false;
}
if (/\S/.test(children[i].getTextContent())) {
return true;
}
}
return false;
}
function $extractMarkdownHardLineBreakMarker(previousNode) {
const children = previousNode.getChildren();
const lastChildIndex = children.length - 1;
const lastChild = children[lastChildIndex];
if (!lexical.$isTextNode(lastChild)) {
return null;
}
const lastText = lastChild.getTextContent();
const hardLineBreak = parseMarkdownHardLineBreak(lastText);
if (hardLineBreak !== null) {
const [text, marker] = hardLineBreak;
lastChild.setTextContent(text);
return marker;
}
if (/^ {2,}$/.test(lastText) && hasNonWhitespaceContentOnLine(children, lastChildIndex)) {
lastChild.setTextContent('');
return lastText;
}
return null;
}
function $createMarkdownLineBreakNode(previousNode) {
const lineBreakNode = lexical.$createLineBreakNode();
const hardLineBreak = $extractMarkdownHardLineBreakMarker(previousNode);
if (hardLineBreak !== null) {
lexical.$setState(lineBreakNode, hardLineBreakState, hardLineBreak);
}
return lineBreakNode;
}
const createBlockNode = createNode => {
return (parentNode, children, match, isImport) => {
const node = createNode(match);
node.append(...children);
parentNode.replace(node);
if (!isImport) {
node.select(0, 0);
}
};
};
// Amount of spaces that define indentation level
// TODO: should be an option
const LIST_INDENT_SIZE = 4;
function getIndent(whitespaces) {
const tabs = whitespaces.match(/\t/g);
const spaces = whitespaces.match(/ /g);
let indent = 0;
if (tabs) {
indent += tabs.length;
}
if (spaces) {
indent += Math.floor(spaces.length / LIST_INDENT_SIZE);
}
return indent;
}
const listReplace = listType => {
return (parentNode, children, match, isImport) => {
if (richText.$isHeadingNode(parentNode)) {
return false;
}
const previousNode = parentNode.getPreviousSibling();
const nextNode = parentNode.getNextSibling();
const listItem = list.$createListItemNode(listType === 'check' ? match[3] === 'x' : undefined);
const firstMatchChar = match[0].trim()[0];
const listMarker = (listType === 'bullet' || listType === 'check') && firstMatchChar === listMarkerState.parse(firstMatchChar) ? firstMatchChar : undefined;
if (list.$isListNode(nextNode) && nextNode.getListType() === listType) {
if (listMarker) {
lexical.$setState(nextNode, listMarkerState, listMarker);
}
const firstChild = nextNode.getFirstChild();
if (firstChild !== null) {
firstChild.insertBefore(listItem);
} else {
// should never happen, but let's handle gracefully, just in case.
nextNode.append(listItem);
}
// The new list item lands at index 0, so the typed number becomes the
// list's starting value. #8677.
if (listType === 'number') {
nextNode.setStart(Number(match[2]));
}
parentNode.remove();
} else if (list.$isListNode(previousNode) && previousNode.getListType() === listType) {
if (listMarker) {
lexical.$setState(previousNode, listMarkerState, listMarker);
}
// The new item is appended at the end and inherits the existing
// sequence, so the typed number is intentionally ignored here.
previousNode.append(listItem);
parentNode.remove();
} else {
const list$1 = list.$createListNode(listType, listType === 'number' ? Number(match[2]) : undefined);
if (listMarker) {
lexical.$setState(list$1, listMarkerState, listMarker);
}
list$1.append(listItem);
parentNode.replace(list$1);
}
listItem.append(...children);
if (!isImport) {
listItem.select(0, 0);
}
const indent = getIndent(match[1]);
if (indent) {
listItem.setIndent(indent);
}
};
};
const $listExport = (listNode, exportChildren, depth, selection) => {
const output = [];
const children = listNode.getChildren();
let index = 0;
for (const listItemNode of children) {
if (list.$isListItemNode(listItemNode)) {
if (listItemNode.getChildrenSize() === 1) {
const firstChild = listItemNode.getFirstChild();
if (list.$isListNode(firstChild)) {
const nestedResult = $listExport(firstChild, exportChildren, depth + 1, selection);
if (nestedResult) {
output.push(nestedResult);
}
continue;
}
}
// Skip unselected list items when selection is provided
if (selection && !listItemNode.getChildren().some(child => child.isSelected(selection))) {
continue;
}
const indent = ' '.repeat(depth * LIST_INDENT_SIZE);
const listType = listNode.getListType();
const listMarker = lexical.$getState(listNode, listMarkerState);
const prefix = listType === 'number' ? `${listNode.getStart() + index}. ` : listType === 'check' ? `${listMarker} [${listItemNode.getChecked() ? 'x' : ' '}] ` : listMarker + ' ';
let childrenText = exportChildren(listItemNode);
if (listType !== 'number') {
childrenText = childrenText.replace(/^(\s{0,3}\d+)(\.\s)/, '$1\\$2');
}
output.push(indent + prefix + childrenText);
index++;
}
}
return output.join('\n');
};
const HEADING = {
dependencies: [richText.HeadingNode],
export: (node, exportChildren) => {
if (!richText.$isHeadingNode(node)) {
return null;
}
const level = Number(node.getTag().slice(1));
return '#'.repeat(level) + ' ' + exportChildren(node);
},
regExp: HEADING_REGEX,
replace: createBlockNode(match => {
const tag = 'h' + match[1].length;
return richText.$createHeadingNode(tag);
}),
triggerOnEnter: true,
type: 'element'
};
const QUOTE = {
dependencies: [richText.QuoteNode],
export: (node, exportChildren) => {
if (!richText.$isQuoteNode(node)) {
return null;
}
const lines = exportChildren(node).split('\n');
const output = [];
for (const line of lines) {
output.push('> ' + line);
}
return output.join('\n');
},
regExp: QUOTE_REGEX,
replace: (parentNode, children, _match, isImport) => {
if (isImport) {
const previousNode = parentNode.getPreviousSibling();
if (richText.$isQuoteNode(previousNode)) {
previousNode.splice(previousNode.getChildrenSize(), 0, [$createMarkdownLineBreakNode(previousNode), ...children]);
parentNode.remove();
return;
}
}
const node = richText.$createQuoteNode();
node.append(...children);
parentNode.replace(node);
if (!isImport) {
node.select(0, 0);
}
},
triggerOnEnter: true,
type: 'element'
};
const CODE = {
dependencies: [codeCore.CodeNode],
export: node => {
if (!codeCore.$isCodeNode(node)) {
return null;
}
const textContent = node.getTextContent();
let fence = lexical.$getState(node, codeFenceState);
if (textContent.indexOf(fence) > -1) {
const backticks = textContent.match(/`{3,}/g);
if (backticks) {
const maxLength = Math.max(...backticks.map(b => b.length));
fence = '`'.repeat(maxLength + 1);
}
}
return fence + (node.getLanguage() || '') + (textContent ? '\n' + textContent : '') + '\n' + fence;
},
handleImportAfterStartMatch: ({
lines,
rootNode,
startLineIndex,
startMatch
}) => {
const fence = startMatch[1];
const fenceLength = fence.trim().length;
const currentLine = lines[startLineIndex];
const afterFenceIndex = startMatch.index + fence.length;
const afterFence = currentLine.slice(afterFenceIndex);
const singleLineEndRegex = new RegExp(`\`{${fenceLength},}$`);
if (singleLineEndRegex.test(afterFence)) {
const endMatch = afterFence.match(singleLineEndRegex);
const content = afterFence.slice(0, afterFence.lastIndexOf(endMatch[0]));
const fakeStartMatch = [...startMatch];
fakeStartMatch[2] = '';
CODE.replace(rootNode, null, fakeStartMatch, endMatch, [content], true);
return [true, startLineIndex];
}
const multilineEndRegex = new RegExp(`^[ \\t]*\`{${fenceLength},}$`);
for (let i = startLineIndex + 1; i < lines.length; i++) {
const line = lines[i];
if (multilineEndRegex.test(line)) {
const endMatch = line.match(multilineEndRegex);
const linesInBetween = lines.slice(startLineIndex + 1, i);
const afterFullMatch = currentLine.slice(startMatch[0].length);
if (afterFullMatch.length > 0) {
linesInBetween.unshift(afterFullMatch);
}
CODE.replace(rootNode, null, startMatch, endMatch, linesInBetween, true);
return [true, i];
}
}
const linesInBetween = lines.slice(startLineIndex + 1);
const afterFullMatch = currentLine.slice(startMatch[0].length);
if (afterFullMatch.length > 0) {
linesInBetween.unshift(afterFullMatch);
}
CODE.replace(rootNode, null, startMatch, null, linesInBetween, true);
return [true, lines.length - 1];
},
regExpEnd: {
optional: true,
regExp: CODE_END_REGEX
},
regExpStart: CODE_START_REGEX,
replace: (rootNode, children, startMatch, endMatch, linesInBetween, isImport) => {
let codeBlockNode;
let code;
const fence = startMatch[1] ? startMatch[1].trim() : '```';
const language = startMatch[2] || undefined;
if (!children && linesInBetween) {
if (linesInBetween.length === 1) {
if (endMatch) {
codeBlockNode = codeCore.$createCodeNode(language);
code = linesInBetween[0];
} else {
codeBlockNode = codeCore.$createCodeNode(language);
code = linesInBetween[0].startsWith(' ') ? linesInBetween[0].slice(1) : linesInBetween[0];
}
} else {
codeBlockNode = codeCore.$createCodeNode(language);
if (linesInBetween.length > 0) {
if (linesInBetween[0].trim().length === 0) {
linesInBetween.shift();
} else if (linesInBetween[0].startsWith(' ')) {
linesInBetween[0] = linesInBetween[0].slice(1);
}
}
while (linesInBetween.length > 0 && !linesInBetween[linesInBetween.length - 1].length) {
linesInBetween.pop();
}
code = linesInBetween.join('\n');
}
lexical.$setState(codeBlockNode, codeFenceState, fence);
const textNode = lexical.$createTextNode(code);
codeBlockNode.append(textNode);
rootNode.append(codeBlockNode);
} else if (children) {
createBlockNode(match => {
return codeCore.$createCodeNode(match ? match[2] : undefined);
})(rootNode, children, startMatch, isImport);
}
},
type: 'multiline-element'
};
const UNORDERED_LIST = {
dependencies: [list.ListNode, list.ListItemNode],
export: (node, exportChildren, selection) => {
return list.$isListNode(node) ? $listExport(node, exportChildren, 0, selection) : null;
},
regExp: UNORDERED_LIST_REGEX,
replace: listReplace('bullet'),
triggerOnEnter: true,
type: 'element'
};
const CHECK_LIST = {
dependencies: [list.ListNode, list.ListItemNode],
export: (node, exportChildren, selection) => {
return list.$isListNode(node) ? $listExport(node, exportChildren, 0, selection) : null;
},
regExp: CHECK_LIST_REGEX,
replace: listReplace('check'),
triggerOnEnter: true,
type: 'element'
};
const ORDERED_LIST = {
dependencies: [list.ListNode, list.ListItemNode],
export: (node, exportChildren, selection) => {
return list.$isListNode(node) ? $listExport(node, exportChildren, 0, selection) : null;
},
regExp: ORDERED_LIST_REGEX,
replace: listReplace('number'),
triggerOnEnter: true,
type: 'element'
};
const INLINE_CODE = {
format: ['code'],
tag: '`',
type: 'text-format'
};
// Computes a CommonMark-compliant fence and padded content for an inline code
// span: https://spec.commonmark.org/#code-spans
function getCodeSpanDelimiter(content) {
const backtickRuns = content.match(/`+/g);
const longestRun = backtickRuns ? Math.max(...backtickRuns.map(run => run.length)) : 0;
const fence = '`'.repeat(longestRun + 1);
const needsPadding = content.length === 0 || content.includes('`') || /^\s/.test(content) && /\s$/.test(content);
const padded = needsPadding ? ` ${content} ` : content;
return {
fence,
padded
};
}
const HIGHLIGHT = {
format: ['highlight'],
tag: '==',
type: 'text-format'
};
const BOLD_ITALIC_STAR = {
format: ['bold', 'italic'],
tag: '***',
type: 'text-format'
};
const BOLD_ITALIC_UNDERSCORE = {
format: ['bold', 'italic'],
intraword: false,
tag: '___',
type: 'text-format'
};
const BOLD_STAR = {
format: ['bold'],
tag: '**',
type: 'text-format'
};
const BOLD_UNDERSCORE = {
format: ['bold'],
intraword: false,
tag: '__',
type: 'text-format'
};
const STRIKETHROUGH = {
format: ['strikethrough'],
tag: '~~',
type: 'text-format'
};
const ITALIC_STAR = {
format: ['italic'],
tag: '*',
type: 'text-format'
};
const ITALIC_UNDERSCORE = {
format: ['italic'],
intraword: false,
tag: '_',
type: 'text-format'
};
// Order of text transformers matters:
//
// - code should go first as it prevents any transformations inside
// - then longer tags match (e.g. ** or __ should go before * or _)
const LINK = {
dependencies: [link.LinkNode],
export: (node, exportChildren, exportFormat) => {
if (!link.$isLinkNode(node) || link.$isAutoLinkNode(node)) {
return null;
}
const textContent = exportChildren(node);
let title = node.getTitle();
if (title != null) {
title = title.replace(/([\\"])/g, '\\$1');
}
const linkContent = title ? `[${textContent}](${node.getURL()} "${title}")` : `[${textContent}](${node.getURL()})`;
return linkContent;
},
importRegExp: /(?:\[(.+?)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/,
regExp: /(?:\[([^[\]]*(?:\[[^[\]]*\][^[\]]*)*)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/,
replace: (textNode, match) => {
// https://spec.commonmark.org/0.31.2/#inline-link
if (lexical.$findMatchingParent(textNode, link.$isLinkNode)) {
return;
}
const [, linkText, rawLinkUrl, rawLinkTitle] = match;
const linkUrl = rawLinkUrl != null ? unescapeText(rawLinkUrl) : undefined;
const linkTitle = rawLinkTitle != null ? unescapeText(rawLinkTitle) : undefined;
const linkNode = link.$createLinkNode(linkUrl, {
title: linkTitle
});
const openBracketAmount = linkText.split('[').length - 1;
const closeBracketAmount = linkText.split(']').length - 1;
let parsedLinkText = linkText;
let outsideLinkText = '';
if (openBracketAmount < closeBracketAmount) {
return;
} else if (openBracketAmount > closeBracketAmount) {
const linkTextParts = linkText.split('[');
outsideLinkText = '[' + linkTextParts[0];
parsedLinkText = linkTextParts.slice(1).join('[');
}
const linkTextNode = lexical.$createTextNode(parsedLinkText);
linkTextNode.setFormat(textNode.getFormat());
linkNode.append(linkTextNode);
textNode.replace(linkNode);
if (outsideLinkText) {
linkNode.insertBefore(lexical.$createTextNode(outsideLinkText));
}
return linkTextNode;
},
trigger: ')',
type: 'text-match'
};
const ELEMENT_TRANSFORMERS = [HEADING, QUOTE, UNORDERED_LIST, ORDERED_LIST];
const MULTILINE_ELEMENT_TRANSFORMERS = [CODE];
// Order of text format transformers matters:
//
// - code should go first as it prevents any transformations inside
// - then longer tags match (e.g. ** or __ should go before * or _)
const TEXT_FORMAT_TRANSFORMERS = [INLINE_CODE, BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, HIGHLIGHT, ITALIC_STAR, ITALIC_UNDERSCORE, STRIKETHROUGH];
const TEXT_MATCH_TRANSFORMERS = [LINK];
const TRANSFORMERS = [...ELEMENT_TRANSFORMERS, ...MULTILINE_ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS];
function normalizeMarkdown(input, shouldMergeAdjacentLines = false) {
const lines = input.split('\n');
let codeBlockFenceLength = 0;
const sanitizedLines = [];
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
const line = rawLine.trimEnd();
const lastLine = sanitizedLines[sanitizedLines.length - 1];
const hardLineBreak = i < lines.length - 1 ? parseMarkdownHardLineBreak(rawLine) : null;
const lastLineHasHardLineBreak = lastLine !== undefined && parseMarkdownHardLineBreak(lastLine) !== null;
// Code blocks of ```single line``` don't toggle the inCodeBlock flag
if (CODE_SINGLE_LINE_REGEX.test(line)) {
sanitizedLines.push(line);
continue;
}
if (codeBlockFenceLength === 0) {
// An opening fence may carry an info string (e.g. ```ts)
const openMatch = line.match(CODE_START_REGEX);
if (openMatch) {
codeBlockFenceLength = openMatch[1].trim().length;
sanitizedLines.push(line);
continue;
}
} else {
// A code block is closed only by a bare fence (no info string) that is at
// least as long as the opening fence. Fence-like lines that carry an info
// string (e.g. a nested ```ts) are part of the code block's content.
if (CODE_END_REGEX.test(line) && line.trim().length >= codeBlockFenceLength) {
codeBlockFenceLength = 0;
sanitizedLines.push(line);
continue;
}
// Inside a code block, keep the line unchanged
sanitizedLines.push(rawLine);
continue;
}
// In markdown the concept of "empty paragraphs" does not exist.
// Blocks must be separated by an empty line. Non-empty adjacent lines must be merged.
if (line === '' || lastLine === '' || !lastLine || HEADING_REGEX.test(lastLine) || HEADING_REGEX.test(line) || QUOTE_REGEX.test(line) || ORDERED_LIST_REGEX.test(line) || UNORDERED_LIST_REGEX.test(line) || CHECK_LIST_REGEX.test(line) || TABLE_ROW_REG_EXP.test(line) || isTableRowDivider(line) || lastLineHasHardLineBreak || !shouldMergeAdjacentLines || TAG_START_REGEX.test(line) || TAG_END_REGEX.test(line) || ENDS_WITH(TAG_END_REGEX).test(lastLine) || ENDS_WITH(TAG_START_REGEX).test(lastLine) || CODE_END_REGEX.test(lastLine)) {
// When not merging, preserve trailing whitespace (e.g. hard line-break
// markers " " or non-breaking spaces). Whitespace-only lines still
// collapse to '' because trimEnd() already reduced them, so they
// continue to act as paragraph separators.
sanitizedLines.push(!shouldMergeAdjacentLines && line !== '' || hardLineBreak !== null ? rawLine : line);
} else {
sanitizedLines[sanitizedLines.length - 1] = lastLine + ' ' + (hardLineBreak === null ? line : rawLine).trimStart();
}
}
return sanitizedLines.join('\n');
}
/**
* 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.
*
*/
/**
* Renders string from markdown. The selection is moved to the start after the operation.
*/
function createMarkdownExport(transformers, shouldPreserveNewLines = false) {
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 || lexical.$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.
*/
function createSelectionMarkdownExport(transformers, shouldPreserveNewLines = false) {
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 = lexical.$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, selection, elementTransformers, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines) {
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 (lexical.$isElementNode(node)) {
const childResult = $exportChildrenForSelection(node, selection, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines);
if (childResult.shouldInclude) {
shouldInclude = true;
}
}
}
return {
markdown: result,
shouldInclude
};
}
}
if (lexical.$isElementNode(node)) {
const childResult = $exportChildrenForSelection(node, selection, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines);
return {
markdown: childResult.markdown,
shouldInclude: shouldInclude || childResult.shouldInclude
};
} else if (lexical.$isDecoratorNode(node)) {
return {
markdown: node.getTextContent(),
shouldInclude
};
} else {
return {
markdown: null,
shouldInclude
};
}
}
function $exportChildrenForSelection(node, selection$1, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines, unclosedTags, unclosableTags) {
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$1);
// Try text match transformers (links, etc.)
for (const transformer of textMatchTransformers) {
if (!transformer.export) {
continue;
}
const result = transformer.export(child, parentNode => $exportChildrenForSelection(parentNode, selection$1, textFormatTransformers, textMatchTransformers, shouldPreserveNewLines, unclosedTags, [...unclosableTags, ...unclosedTags]).markdown, (textNode, textContent) => {
const slicedNode = selection.$sliceSelectedTextNodeContent(selection$1, 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 && lexical.$isElementNode(child) && child.getChildren().some(c => c.isSelected(selection$1)) && child.extractWithChild(child, selection$1, 'html')) {
childIncluded = true;
}
if (childIncluded) {
output.push(result);
anyChildIncluded = true;
}
continue mainLoop;
}
}
if (lexical.$isLineBreakNode(child)) {
if (childIncluded) {
output.push($exportLineBreak(child));
anyChildIncluded = true;
}
} else if (lexical.$isTextNode(child)) {
if (childIncluded) {
const target = selection.$sliceSelectedTextNodeContent(selection$1, child, 'clone');
output.push(exportTextFormat(child, target.getTextContent(), textFormatTransformers, unclosedTags, unclosableTags, shouldPreserveNewLines));
anyChildIncluded = true;
}
} else if (lexical.$isElementNode(child)) {
const childResult = $exportChildrenForSelection(child, selection$1, 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$1, 'html')) {
childIncluded = true;
}
if (childIncluded || childResult.shouldInclude) {
output.push(childResult.markdown);
anyChildIncluded = true;
}
} else if (lexical.$isDecoratorNode(child)) {
if (childIncluded) {
output.push(child.getTextContent());
anyChildIncluded = true;
}
}
}
return {
markdown: output.join(''),
shouldInclude: anyChildIncluded
};
}
function $exportTopLevelElements(node, elementTransformers, textTransformersIndex, textMatchTransformers, shouldPreserveNewLines) {
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 (lexical.$isElementNode(node)) {
return $exportChildren(node, textTransformersIndex, textMatchTransformers, undefined, undefined, shouldPreserveNewLines);
} else if (lexical.$isDecoratorNode(node)) {
return node.getTextContent();
} else {
return null;
}
}
function $exportChildren(node, textTransformersIndex, textMatchTransformers, unclosedTags, unclosableTags, shouldPreserveNewLines = false) {
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 (lexical.$isLineBreakNode(child)) {
output.push($exportLineBreak(child));
} else if (lexical.$isTextNode(child)) {
output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex, unclosedTags, unclosableTags, shouldPreserveNewLines));
} else if (lexical.$isElementNode(child)) {
// empty paragraph returns ""
output.push($exportChildren(child, textTransformersIndex, textMatchTransformers, unclosedTags, unclosableTags, shouldPreserveNewLines));
} else if (lexical.$isDecoratorNode(child)) {
output.push(child.getTextContent());
}
}
return output.join('');
}
function $exportLineBreak(node) {
return lexical.$getState(node, hardLineBreakState) + '\n';
}
function exportTextFormat(node, textContent, textTransformers,
// unclosed tags include the markdown tags that haven't been closed yet, and their associated formats
unclosedTags, unclosableTags, shouldPreserveNewLines = false) {
// 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;
let trimmedOutput;
let trailingSpace;
let isWhitespaceOnly;
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, backward) {
const sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
if (lexical.$isTextNode(sibling)) {
return sibling;
}
return null;
}
function hasFormat(node, format) {
return lexical.$isTextNode(node) && node.hasFormat(format);
}
function checkHasFormat(n, f) {
if (!hasFormat(n, f)) {
return false;
}
if (f === 'code') {
return true;
}
if (n && /^\s*$/.test(n.getTextContent())) {
return false;
}
return true;
}
/**
* 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.
*
*/
function findOutermostTextFormatTransformer(textNode, textFormatTransformersIndex) {
const textContent = textNode.getTextContent();
// Find code spans first. Emphasis delimiters inside inline elements (e.g., code spans)
// should not be processed. Currently only code spans are handled; other inline elements
// (e.g., links, raw HTML) may need similar treatment in the future.
const codeTransformer = textFormatTransformersIndex.transformersByTag['`'];
const excludeRanges = [];
let codeMatch = null;
if (codeTransformer) {
const codeSpans = scanCodeSpans(textContent);
for (const span of codeSpans) {
if (!codeMatch) {
codeMatch = {
content: span.content,
endIndex: span.endIndex,
startIndex: span.startIndex,
tag: '`'
};
}
excludeRanges.push({
end: span.endIndex,
start: span.startIndex
});
}
}
const delimiters = scanDelimiters(textContent, textFormatTransformersIndex, excludeRanges);
const emphasisMatch = delimiters.length > 0 ? processEmphasis(textContent, delimiters, textFormatTransformersIndex) : null;
let resultMatch = null;
let resultTransformer = null;
if (codeMatch && emphasisMatch) {
if (emphasisMatch.startIndex <= codeMatch.startIndex && emphasisMatch.endIndex >= codeMatch.endIndex) {
resultMatch = emphasisMatch;
resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag];
} else {
resultMatch = codeMatch;
resultTransformer = codeTransformer;
}
} else if (codeMatch) {
resultMatch = codeMatch;
resultTransformer = codeTransformer;
} else if (emphasisMatch) {
resultMatch = emphasisMatch;
resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag];
}
if (!resultMatch || !resultTransformer) {
return null;
}
const regexMatch = [textContent.slice(resultMatch.startIndex, resultMatch.endIndex), resultMatch.tag, resultMatch.content];
regexMatch.index = resultMatch.startIndex;
regexMatch.input = textContent;
return {
endIndex: resultMatch.endIndex,
// resultTransformer is the registered code transformer (by identity)
// exactly when the chosen match is the code span.
isCodeSpan: resultTransformer === codeTransformer,
match: regexMatch,
startIndex: resultMatch.startIndex,
transformer: resultTransformer
};
}
// Finds all inline code spans, left to right and non-overlapping, per CommonMark
// rules: https://spec.commonmark.org/#code-spans. A run opens a span and the
// next run of equal length closes it. An escaped backtick (`\``) cannot open a
// span, but backslashes are otherwise literal and don't prevent closing.
function scanCodeSpans(text) {
const isEscaped = index => {
let count = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
count++;
}
return count % 2 === 1;
};
// Collect maximal backtick runs.
const runs = [];
let i = 0;
while (i < text.length) {
if (text[i] === '`') {
let length = 1;
while (i + length < text.length && text[i + length] === '`') {
length++;
}
runs.push({
index: i,
length
});
i += length;
} else {
i++;
}
}
const spans = [];
let openIdx = 0;
while (openIdx < runs.length) {
const opener = runs[openIdx];
// An escaped backtick run is a literal backtick and cannot open a span.
if (isEscaped(opener.index)) {
openIdx++;
continue;
}
let closeIdx = -1;
for (let c = openIdx + 1; c < runs.length; c++) {
if (runs[c].length === opener.length) {
closeIdx = c;
break;
}
}
if (closeIdx === -1) {
// No matching closer; treat this run as literal and try the next one.
openIdx++;
continue;
}
const closer = runs[closeIdx];
let content = text.slice(opener.index + opener.length, closer.index);
if (content.length >= 2 && content.startsWith(' ') && content.endsWith(' ') && /[^ ]/.test(content)) {
content = content.slice(1, -1);
}
spans.push({
content,
endIndex: closer.index + closer.length,
startIndex: opener.index
});
openIdx = closeIdx + 1;
}
return spans;
}
function scanDelimiters(text, transformersIndex, excludeRanges = []) {
const delimiters = [];
const delimiterChars = new Set(Object.keys(transformersIndex.transformersByTag).filter(tag => tag[0] !== '`').map(tag => tag[0]));
const isEscaped = index => {
let count = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
count++;
}
return count % 2 === 1;
};
const isInExcludedRange = index => {
return excludeRanges.some(range => index >= range.start && index < range.end);
};
let i = 0;
while (i < text.length) {
const char = text[i];
if (!delimiterChars.has(char) || isEscaped(i) || isInExcludedRange(i)) {
i++;
continue;
}
let len = 1;
while (i + len < text.length && text[i + len] === char) {
len++;
}
const canOpen = canEmphasis(char, text, i, len, true);
const canClose = canEmphasis(char, text, i, len, false);
if (canOpen || canClose) {
delimiters.push({
active: true,
canClose,
canOpen,
char,
index: i,
length: len
});
}
i += len;
}
return delimiters;
}
function processEmphasis(text, delimiters, transformersIndex) {
const openersBottom = {};
let currentPos = 0;
let result = null;
while (currentPos < delimiters.length) {
const closer = delimiters[currentPos];
if (!closer.active || !closer.canClose || closer.length === 0) {
currentPos++;
continue;
}
// The "no opener below this point" shortcut must be keyed by everything
// the opener search outcome depends on: the marker, whether the closer
// can also open (toggles the rule of 3), and the closer's length mod 3
// (the rule of 3 blocks different openers per class — CommonMark's
// openers_bottom is likewise per length-mod-3). The length key uses the
// *current* length: a partially consumed closer switches class and must
// rescan openers a shorter closer already gave up on (#4895).
const bottomKey = `${closer.char}${closer.canOpen}${closer.length % 3}`;
const bottom = openersBottom[bottomKey] ?? -1;
let foundOpener = false;
for (let openIdx = currentPos - 1; openIdx > bottom; openIdx--) {
const opener = delimiters[openIdx];
if (!opener.active || !opener.canOpen || opener.length === 0 || opener.char !== closer.char) {
continue;
}
// Rule of 3, on the *remaining* lengths: a delimiter run partially
// consumed by an earlier pairing is re-measured, matching micromark
// (the CommonMark reference this models). Using the original lengths
// instead would refuse pairings like the `**` opener with the two
// markers left of a `****` run in `**llo*wor****`, so the exporter's
// own output for overlapping formats would not re-import (#4895).
if (opener.canClose || closer.canOpen) {
const sum = opener.length + closer.length;
if (sum % 3 === 0 && opener.length % 3 !== 0 && closer.length % 3 !== 0) {
continue;
}
}
const maxLen = Math.min(opener.length, closer.length);
const matchedTag = Object.keys(transformersIndex.transformersByTag).filter(t => t[0] === opener.char && t.length <= maxLen).sort((a, b) => b.length - a.length)[0];
if (!matchedTag) {
continue;
}
foundOpener = true;
const matchLen = matchedTag.length;
const match = {
content: text.slice(opener.index + opener.length, closer.index),
endIndex: closer.index + matchLen,
startIndex: opener.index + (opener.length - matchLen),
tag: matchedTag
};
if (!result || match.startIndex < result.startIndex || match.startIndex === result.startIndex && match.endIndex > result.endIndex) {
result = match;
}
for (let j = openIdx + 1; j < currentPos; j++) {
delimiters[j].active = false;
}
opener.length -= matchLen;
closer.length -= matchLen;
opener.active = opener.length > 0;
if (closer.length > 0) {
closer.index += matchLen;
} else {
closer.active = false;
currentPos++;
}
break;
}
if (!foundOpener) {
openersBottom[bottomKey] = currentPos - 1;
if (!closer.canOpen) {
closer.active = false;
}
currentPos++;
}
}
return result;
}
function canEmphasis(char, text, index, length, isOpen) {
if (!isFlanking(text, index, length, isOpen)) {
return false;
}
if (char === '*') {
return true;
}
if (char === '_') {
if (!isFlanking(text, index, length, !isOpen)) {
return true;
}
const adjacentChar = isOpen ? text[index - 1] : text[index + length];
return adjacentChar !== undefined && PUNCTUATION.test(adjacentChar);
}
return true;
}
function isFlanking(text, index, length, isLeft) {
const charBefore = text[index - 1];
const charAfter = text[index + length];
const [primary, secondary] = isLeft ? [charAfter, charBefore] : [charBefore, charAfter];
if (primary === undefined || WHITESPACE.test(primary)) {
return false;
}
if (!PUNCTUATION.test(primary)) {
return true;
}
return secondary === undefined || WHITESPACE.test(secondary) || PUNCTUATION.test(secondary);
}
function importTextFormatTransformer(textNode, startIndex, endIndex, transformer, match) {
const textContent = textNode.getTextContent();
// No text matches - we can safely process the text format match
let transformedNode, nodeAfter, nodeBefore;
// If matching full content there's