Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[lexical-markdown][breaking change] Feature: multiline markdown transformers / mdx support #6530

Merged
merged 38 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ec0595c
feat: multiline markdown transformers
AlessioGr Aug 19, 2024
b277b15
chore: fix @lexical/markdown package referencing itself
AlessioGr Aug 19, 2024
8e66055
feat: add ability to allow future transformer to run by returning fal…
AlessioGr Aug 19, 2024
24cc82a
chore: add test for multiline markdown html transformer (useful for p…
AlessioGr Aug 19, 2024
7d4a904
chore: fix tests, add new tests
AlessioGr Aug 19, 2024
3cace56
chore: export CODE_MULTILINE and MultilineElementTransformer
AlessioGr Aug 19, 2024
ebcc28c
fix babel warnings
AlessioGr Aug 20, 2024
0fef215
fix: incorrect handling of multiline imports if closeMatch is on the …
AlessioGr Aug 21, 2024
bb7c9c4
Merge branch 'main' into feat/multiline-markdown
etrepum Aug 21, 2024
1b459b3
chore: simplify and optimize multiline markdown transformers
AlessioGr Aug 23, 2024
acd4575
fix: single-line code blocks not handled properly in markdown transfo…
AlessioGr Aug 23, 2024
e75578a
Merge branch 'main' into feat/multiline-markdown
AlessioGr Aug 23, 2024
278477c
add additional test
AlessioGr Aug 23, 2024
b7bb52a
ensure multiline element transformers are exported and used in playgr…
AlessioGr Aug 23, 2024
1131488
initialize elementTransformers array only once
AlessioGr Aug 23, 2024
dc143ef
remove loop labels
AlessioGr Aug 23, 2024
2fe2c36
clean up jsdocs
AlessioGr Aug 23, 2024
50570b4
rename openMatch => startMatch and closeMatch => endMatch
AlessioGr Aug 23, 2024
49e7ee4
attempt to fix potentially flaky test
AlessioGr Aug 23, 2024
854e3b1
CI test - ignore
AlessioGr Aug 23, 2024
bf60e34
CI test - ignore
AlessioGr Aug 23, 2024
2d98c46
Revert "CI test - ignore"
AlessioGr Aug 25, 2024
c190688
Revert "CI test - ignore"
AlessioGr Aug 25, 2024
d6da277
chore: fix incorrect Should not break while importing and exporting m…
AlessioGr Aug 25, 2024
a6ee73a
Revert "chore: fix incorrect Should not break while importing and exp…
AlessioGr Aug 26, 2024
2801b76
feat: optional regexEnd
AlessioGr Aug 26, 2024
ea00670
make sure regExp property in regexpEnd cannot be optional
AlessioGr Aug 26, 2024
ca2cb36
add new tests, handle optional endMatch nezzer
AlessioGr Aug 26, 2024
648fdd6
fix type error
AlessioGr Aug 26, 2024
91d25be
Merge branch 'main' into feat/multiline-markdown
AlessioGr Aug 26, 2024
f80ffdc
undo Hashtags e2e test diff
AlessioGr Aug 26, 2024
7518bb2
Merge remote-tracking branch 'origin/feat/multiline-markdown' into fe…
AlessioGr Aug 26, 2024
28c9def
small optimization
AlessioGr Aug 26, 2024
03ffd3d
feat: allow multiline markdown transformers to be run as markdown sho…
AlessioGr Aug 26, 2024
cadf44a
make typescript happy
AlessioGr Aug 26, 2024
15fe9ec
fix more typescript errors, add more jsdocs
AlessioGr Aug 26, 2024
7db3f31
fix code nodes created through markdown shortcut plugin being incorre…
AlessioGr Aug 26, 2024
1cf1361
fix typescript errors
AlessioGr Aug 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/lexical-markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ LINK
And bundles of commonly used transformers:
- `TRANSFORMERS` - all built-in transformers
- `ELEMENT_TRANSFORMERS` - all built-in element transformers
- `MULTILINE_ELEMENT_TRANSFORMERS` - all built-in multiline element transformers
- `TEXT_FORMAT_TRANSFORMERS` - all built-in text format transformers
- `TEXT_MATCH_TRANSFORMERS` - all built-in text match transformers

Expand Down
25 changes: 15 additions & 10 deletions packages/lexical-markdown/src/MarkdownExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@
*
*/

import type {
ElementTransformer,
TextFormatTransformer,
TextMatchTransformer,
Transformer,
} from '@lexical/markdown';
import type {ElementNode, LexicalNode, TextFormatType, TextNode} from 'lexical';

import {
Expand All @@ -22,6 +16,13 @@ import {
$isTextNode,
} from 'lexical';

import {
ElementTransformer,
MultilineElementTransformer,
TextFormatTransformer,
TextMatchTransformer,
Transformer,
} from './MarkdownTransformers';
import {isEmptyParagraph, transformersByType} from './utils';

/**
Expand All @@ -32,6 +33,7 @@ export function createMarkdownExport(
shouldPreserveNewLines: boolean = false,
): (node?: ElementNode) => string {
const byType = transformersByType(transformers);
const elementTransformers = [...byType.multilineElement, ...byType.element];
const isNewlineDelimited = !shouldPreserveNewLines;

// Export only uses text formats that are responsible for single format
Expand All @@ -48,14 +50,14 @@ export function createMarkdownExport(
const child = children[i];
const result = exportTopLevelElements(
child,
byType.element,
elementTransformers,
textFormatTransformers,
byType.textMatch,
);

if (result != null) {
output.push(
// seperate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"]
// separate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"]
isNewlineDelimited &&
i > 0 &&
!isEmptyParagraph(child) &&
Expand All @@ -65,19 +67,22 @@ export function createMarkdownExport(
);
}
}
// Ensure consecutive groups of texts are atleast \n\n apart while each empty paragraph render as a newline.
// 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');
};
}

function exportTopLevelElements(
node: LexicalNode,
elementTransformers: Array<ElementTransformer>,
elementTransformers: Array<ElementTransformer | MultilineElementTransformer>,
textTransformersIndex: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
): string | null {
for (const transformer of elementTransformers) {
if (!transformer.export) {
continue;
}
const result = transformer.export(node, (_node) =>
exportChildren(_node, textTransformersIndex, textMatchTransformers),
);
Expand Down
164 changes: 121 additions & 43 deletions packages/lexical-markdown/src/MarkdownImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@
*
*/

import type {CodeNode} from '@lexical/code';
import type {
ElementTransformer,
MultilineElementTransformer,
TextFormatTransformer,
TextMatchTransformer,
Transformer,
} from '@lexical/markdown';
} from './MarkdownTransformers';
import type {TextNode} from 'lexical';

import {$createCodeNode} from '@lexical/code';
import {$isListItemNode, $isListNode, ListItemNode} from '@lexical/list';
import {$isQuoteNode} from '@lexical/rich-text';
import {$findMatchingParent} from '@lexical/utils';
Expand All @@ -36,7 +35,6 @@ import {
transformersByType,
} from './utils';

const CODE_BLOCK_REG_EXP = /^[ \t]*```(\w{1,10})?\s?$/;
type TextFormatTransformersIndex = Readonly<{
fullMatchRegExpByTag: Readonly<Record<string, RegExp>>;
openTagsRegExp: RegExp;
Expand All @@ -63,14 +61,20 @@ export function createMarkdownImport(

for (let i = 0; i < linesLength; i++) {
const lineText = lines[i];
// Codeblocks are processed first as anything inside such block
// is ignored for further processing
// TODO:
// Abstract it to be dynamic as other transformers (add multiline match option)
const [codeBlockNode, shiftedIndex] = $importCodeBlock(lines, i, root);

if (codeBlockNode != null) {
i = shiftedIndex;

const [imported, shiftedIndex] = $importMultiline(
lines,
i,
byType.multilineElement,
root,
);

if (imported) {
// If a multiline markdown element was imported, we don't want to process the lines that were part of it anymore.
// There could be other sub-markdown elements (both multiline and normal ones) matching within this matched multiline element's children.
// However, it would be the responsibility of the matched multiline transformer to decide how it wants to handle them.
// We cannot handle those, as there is no way for us to know how to maintain the correct order of generated lexical nodes for possible children.
i = shiftedIndex; // Next loop will start from the line after the last line of the multiline element
continue;
}

Expand Down Expand Up @@ -103,6 +107,108 @@ export function createMarkdownImport(
};
}

/**
*
* @returns first element of the returned tuple is a boolean indicating if a multiline element was imported. The second element is the index of the last line that was processed.
*/
function $importMultiline(
lines: Array<string>,
startLineIndex: number,
multilineElementTransformers: Array<MultilineElementTransformer>,
rootNode: ElementNode,
): [boolean, number] {
for (const {
regExpStart,
regExpEnd,
replace,
} of multilineElementTransformers) {
const startMatch = lines[startLineIndex].match(regExpStart);
if (!startMatch) {
continue; // Try next transformer
}

const regexpEndRegex: RegExp | undefined =
typeof regExpEnd === 'object' && 'regExp' in regExpEnd
? regExpEnd.regExp
: regExpEnd;

const isEndOptional =
regExpEnd && typeof regExpEnd === 'object' && 'optional' in regExpEnd
? regExpEnd.optional
: !regExpEnd;

let endLineIndex = startLineIndex;
const linesLength = lines.length;

// check every single line for the closing match. It could also be on the same line as the opening match.
while (endLineIndex < linesLength) {
const endMatch = regexpEndRegex
? lines[endLineIndex].match(regexpEndRegex)
: null;
if (!endMatch) {
if (
!isEndOptional ||
(isEndOptional && endLineIndex < linesLength - 1) // Optional end, but didn't reach the end of the document yet => continue searching for potential closing match
) {
endLineIndex++;
continue; // Search next line for closing match
}
}

// Now, check if the closing match matched is the same as the opening match.
// If it is, we need to continue searching for the actual closing match.
if (
endMatch &&
startLineIndex === endLineIndex &&
endMatch.index === startMatch.index
) {
endLineIndex++;
continue; // Search next line for closing match
}

// At this point, we have found the closing match. Next: calculate the lines in between open and closing match
// This should not include the matches themselves, and be split up by lines
const linesInBetween = [];

if (endMatch && startLineIndex === endLineIndex) {
linesInBetween.push(
lines[startLineIndex].slice(
startMatch[0].length,
-endMatch[0].length,
),
);
} else {
for (let i = startLineIndex; i <= endLineIndex; i++) {
if (i === startLineIndex) {
const text = lines[i].slice(startMatch[0].length);
linesInBetween.push(text); // Also include empty text
} else if (i === endLineIndex && endMatch) {
const text = lines[i].slice(0, -endMatch[0].length);
linesInBetween.push(text); // Also include empty text
} else {
linesInBetween.push(lines[i]);
}
}
}

if (
replace(rootNode, null, startMatch, endMatch, linesInBetween, true) !==
false
) {
// Return here. This $importMultiline function is run line by line and should only process a single multiline element at a time.
return [true, endLineIndex];
}

// The replace function returned false, despite finding the matching open and close tags => this transformer does not want to handle it.
// Thus, we continue letting the remaining transformers handle the passed lines of text from the beginning
break;
}
}

// No multiline transformer handled this line successfully
return [false, startLineIndex];
}

function $importBlocks(
lineText: string,
rootNode: ElementNode,
Expand All @@ -120,8 +226,9 @@ function $importBlocks(

if (match) {
textNode.setTextContent(lineText.slice(match[0].length));
replace(elementNode, [textNode], match, true);
break;
if (replace(elementNode, [textNode], match, true) !== false) {
break;
}
}
}

Expand Down Expand Up @@ -163,35 +270,6 @@ function $importBlocks(
}
}

function $importCodeBlock(
lines: Array<string>,
startLineIndex: number,
rootNode: ElementNode,
): [CodeNode | null, number] {
const openMatch = lines[startLineIndex].match(CODE_BLOCK_REG_EXP);

if (openMatch) {
let endLineIndex = startLineIndex;
const linesLength = lines.length;

while (++endLineIndex < linesLength) {
const closeMatch = lines[endLineIndex].match(CODE_BLOCK_REG_EXP);

if (closeMatch) {
const codeBlockNode = $createCodeNode(openMatch[1]);
const textNode = $createTextNode(
lines.slice(startLineIndex + 1, endLineIndex).join('\n'),
);
codeBlockNode.append(textNode);
rootNode.append(codeBlockNode);
return [codeBlockNode, endLineIndex];
}
}
}

return [null, startLineIndex];
}

// Processing text content and replaces text format tags.
// It takes outermost tag match and its content, creates text node with
// format based on tag and then recursively executed over node's content
Expand Down
Loading
Loading