@lexical/markdown
Version:
This package contains Markdown helpers and functionality for Lexical.
479 lines (418 loc) • 12.9 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 {TextFormatTransformersIndex} from './MarkdownImport';
import type {TextFormatTransformer} from './MarkdownTransformers';
import type {TextNode} from 'lexical';
import {PUNCTUATION, WHITESPACE} from './utils';
interface Delimiter {
index: number;
char: string;
length: number;
canOpen: boolean;
canClose: boolean;
active: boolean;
}
export function findOutermostTextFormatTransformer(
textNode: TextNode,
textFormatTransformersIndex: TextFormatTransformersIndex,
): {
startIndex: number;
endIndex: number;
transformer: TextFormatTransformer;
match: RegExpMatchArray;
// True when the outermost format is a code span. Code spans are atomic: their
// content is never reprocessed and they bind tighter than text-match
// transformers, so this is tagged here rather than re-derived by callers.
isCodeSpan: boolean;
} | null {
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: {start: number; end: number}[] = [];
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: RegExpMatchArray = [
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: string): {
startIndex: number;
endIndex: number;
content: string;
}[] {
const isEscaped = (index: number): boolean => {
let count = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
count++;
}
return count % 2 === 1;
};
// Collect maximal backtick runs.
const runs: {index: number; length: number}[] = [];
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: {content: string; endIndex: number; startIndex: number}[] = [];
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: string,
transformersIndex: TextFormatTransformersIndex,
excludeRanges: {start: number; end: number}[] = [],
): Delimiter[] {
const delimiters: Delimiter[] = [];
const delimiterChars = new Set(
Object.keys(transformersIndex.transformersByTag)
.filter(tag => tag[0] !== '`')
.map(tag => tag[0]),
);
const isEscaped = (index: number): boolean => {
let count = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
count++;
}
return count % 2 === 1;
};
const isInExcludedRange = (index: number): boolean => {
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: string,
delimiters: Delimiter[],
transformersIndex: TextFormatTransformersIndex,
): {
startIndex: number;
endIndex: number;
tag: string;
content: string;
} | null {
const openersBottom: Record<string, number> = {};
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: string,
text: string,
index: number,
length: number,
isOpen: boolean,
): boolean {
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: string,
index: number,
length: number,
isLeft: boolean,
): boolean {
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)
);
}
export function importTextFormatTransformer(
textNode: TextNode,
startIndex: number,
endIndex: number,
transformer: TextFormatTransformer,
match: RegExpMatchArray,
): {
transformedNode: TextNode;
nodeBefore: TextNode | undefined; // If split
nodeAfter: TextNode | undefined; // If split
} {
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 no need to run splitText and can reuse existing textNode
// to update its content and apply format. E.g. for **_Hello_** string after applying bold
// format (**) it will reuse the same text node to apply italic (_)
if (match[0] === textContent) {
transformedNode = textNode;
} else {
if (startIndex === 0) {
[transformedNode, nodeAfter] = textNode.splitText(endIndex);
} else {
[nodeBefore, transformedNode, nodeAfter] = textNode.splitText(
startIndex,
endIndex,
);
}
}
transformedNode.setTextContent(match[2]);
if (transformer) {
for (const format of transformer.format) {
if (!transformedNode.hasFormat(format)) {
transformedNode.toggleFormat(format);
}
}
}
return {
nodeAfter: nodeAfter,
nodeBefore: nodeBefore,
transformedNode: transformedNode,
};
}