UNPKG

@lexical/markdown

Version:

This package contains Markdown helpers and functionality for Lexical.

1,345 lines (1,307 loc) 85.8 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 { $isParagraphNode, $isTextNode, $setState, $createTextNode, $getState, $findMatchingParent, $createLineBreakNode, createState, $isLineBreakNode, $getRoot, $isElementNode, $isDecoratorNode, $createParagraphNode, $createTabNode, mergeRegister, COLLABORATION_TAG, HISTORIC_TAG, COMPOSITION_END_TAG, $getSelection, $isRangeSelection, $addUpdateTag, HISTORY_PUSH_TAG, KEY_ENTER_COMMAND, COMMAND_PRIORITY_LOW, $isRootOrShadowRoot, $createRangeSelection, $setSelection, TEXT_TYPE_TO_FORMAT, ArtificialNode__DO_NOT_USE } from 'lexical'; import { $sliceSelectedTextNodeContent } from '@lexical/selection'; import { CodeNode, $createCodeNode, $isCodeNode } from '@lexical/code-core'; import { LinkNode, $isLinkNode, $createLinkNode, $isAutoLinkNode } from '@lexical/link'; import { ListNode, ListItemNode, $isListNode, $createListItemNode, $createListNode, $isListItemNode } from '@lexical/list'; import { HeadingNode, QuoteNode, $isHeadingNode, $isQuoteNode, $createQuoteNode, $createHeadingNode } from '@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 (!$isParagraphNode(node)) { return false; } const firstChild = node.getFirstChild(); return firstChild == null || node.getChildrenSize() === 1 && $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__ */createState('mdListMarker', { parse: v => typeof v === 'string' && /^[-*+]$/.test(v) ? v : '-', resetOnCopyNode: true }); const codeFenceState = /* @__PURE__ */createState('mdCodeFence', { parse: val => { if (typeof val === 'string' && /^`{3,}$/.test(val)) { return val; } return '```'; }, resetOnCopyNode: true }); const hardLineBreakState = /* @__PURE__ */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 ($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 (!$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 = $createLineBreakNode(); const hardLineBreak = $extractMarkdownHardLineBreakMarker(previousNode); if (hardLineBreak !== null) { $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 ($isHeadingNode(parentNode)) { return false; } const previousNode = parentNode.getPreviousSibling(); const nextNode = parentNode.getNextSibling(); const listItem = $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 ($isListNode(nextNode) && nextNode.getListType() === listType) { if (listMarker) { $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 ($isListNode(previousNode) && previousNode.getListType() === listType) { if (listMarker) { $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 = $createListNode(listType, listType === 'number' ? Number(match[2]) : undefined); if (listMarker) { $setState(list, listMarkerState, listMarker); } list.append(listItem); parentNode.replace(list); } 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 ($isListItemNode(listItemNode)) { if (listItemNode.getChildrenSize() === 1) { const firstChild = listItemNode.getFirstChild(); if ($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 = $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: [HeadingNode], export: (node, exportChildren) => { if (!$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 $createHeadingNode(tag); }), triggerOnEnter: true, type: 'element' }; const QUOTE = { dependencies: [QuoteNode], export: (node, exportChildren) => { if (!$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 ($isQuoteNode(previousNode)) { previousNode.splice(previousNode.getChildrenSize(), 0, [$createMarkdownLineBreakNode(previousNode), ...children]); parentNode.remove(); return; } } const node = $createQuoteNode(); node.append(...children); parentNode.replace(node); if (!isImport) { node.select(0, 0); } }, triggerOnEnter: true, type: 'element' }; const CODE = { dependencies: [CodeNode], export: node => { if (!$isCodeNode(node)) { return null; } const textContent = node.getTextContent(); let fence = $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 = $createCodeNode(language); code = linesInBetween[0]; } else { codeBlockNode = $createCodeNode(language); code = linesInBetween[0].startsWith(' ') ? linesInBetween[0].slice(1) : linesInBetween[0]; } } else { codeBlockNode = $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'); } $setState(codeBlockNode, codeFenceState, fence); const textNode = $createTextNode(code); codeBlockNode.append(textNode); rootNode.append(codeBlockNode); } else if (children) { createBlockNode(match => { return $createCodeNode(match ? match[2] : undefined); })(rootNode, children, startMatch, isImport); } }, type: 'multiline-element' }; const UNORDERED_LIST = { dependencies: [ListNode, ListItemNode], export: (node, exportChildren, selection) => { return $isListNode(node) ? $listExport(node, exportChildren, 0, selection) : null; }, regExp: UNORDERED_LIST_REGEX, replace: listReplace('bullet'), triggerOnEnter: true, type: 'element' }; const CHECK_LIST = { dependencies: [ListNode, ListItemNode], export: (node, exportChildren, selection) => { return $isListNode(node) ? $listExport(node, exportChildren, 0, selection) : null; }, regExp: CHECK_LIST_REGEX, replace: listReplace('check'), triggerOnEnter: true, type: 'element' }; const ORDERED_LIST = { dependencies: [ListNode, ListItemNode], export: (node, exportChildren, selection) => { return $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: [LinkNode], export: (node, exportChildren, exportFormat) => { if (!$isLinkNode(node) || $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 ($findMatchingParent(textNode, $isLinkNode)) { return; } const [, linkText, rawLinkUrl, rawLinkTitle] = match; const linkUrl = rawLinkUrl != null ? unescapeText(rawLinkUrl) : undefined; const linkTitle = rawLinkTitle != null ? unescapeText(rawLinkTitle) : undefined; const linkNode = $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 = $createTextNode(parsedLinkText); linkTextNode.setFormat(textNode.getFormat()); linkNode.append(linkTextNode); textNode.replace(linkNode); if (outsideLinkText) { linkNode.insertBefore($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 || $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 = $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 ($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, selection, 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); // 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, 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 ($isElementNode(node)) { return $exportChildren(node, textTransformersIndex, textMatchTransformers, undefined, undefined, shouldPreserveNewLines); } else if ($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 ($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) { return $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., "**&#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; 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 ($isTextNode(sibling)) { return sibling; } return null; } function hasFormat(node, format) { return $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 textCon