chore: improve super markdown export when exporting nested nodes
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taken from https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownExport.ts
|
||||||
|
* but modified using changes from https://github.com/facebook/lexical/pull/4957 to make nested elements work
|
||||||
|
* better when exporting to markdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown'
|
||||||
|
import {
|
||||||
|
ElementNode,
|
||||||
|
LexicalNode,
|
||||||
|
TextFormatType,
|
||||||
|
TextNode,
|
||||||
|
$getRoot,
|
||||||
|
$isDecoratorNode,
|
||||||
|
$isElementNode,
|
||||||
|
$isLineBreakNode,
|
||||||
|
$isTextNode,
|
||||||
|
} from 'lexical'
|
||||||
|
import { TRANSFORMERS, transformersByType } from './MarkdownImportExportUtils'
|
||||||
|
|
||||||
|
export function createMarkdownExport(transformers: Array<Transformer>): (node?: ElementNode) => string {
|
||||||
|
const byType = transformersByType(transformers)
|
||||||
|
|
||||||
|
// Export only uses text formats that are responsible for single format
|
||||||
|
// e.g. it will filter out *** (bold, italic) and instead use separate ** and *
|
||||||
|
const textFormatTransformers = byType.textFormat.filter((transformer) => transformer.format.length === 1)
|
||||||
|
|
||||||
|
return (node) => {
|
||||||
|
const output = []
|
||||||
|
const children = (node || $getRoot()).getChildren()
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
const result = exportTopLevelElements(child, byType.element, textFormatTransformers, byType.textMatch)
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
output.push(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join('\n\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTopLevelElements(
|
||||||
|
node: LexicalNode,
|
||||||
|
elementTransformers: Array<ElementTransformer>,
|
||||||
|
textTransformersIndex: Array<TextFormatTransformer>,
|
||||||
|
textMatchTransformers: Array<TextMatchTransformer>,
|
||||||
|
): string | null {
|
||||||
|
for (const transformer of elementTransformers) {
|
||||||
|
const result = transformer.export(node, (_node) =>
|
||||||
|
exportChildren(_node, elementTransformers, textTransformersIndex, textMatchTransformers),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isElementNode(node)) {
|
||||||
|
return exportChildren(node, elementTransformers, textTransformersIndex, textMatchTransformers)
|
||||||
|
} else if ($isDecoratorNode(node)) {
|
||||||
|
return node.getTextContent()
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportChildren(
|
||||||
|
node: ElementNode,
|
||||||
|
elementTransformers: Array<ElementTransformer>,
|
||||||
|
textTransformersIndex: Array<TextFormatTransformer>,
|
||||||
|
textMatchTransformers: Array<TextMatchTransformer>,
|
||||||
|
): string {
|
||||||
|
const output = []
|
||||||
|
const children = node.getChildren()
|
||||||
|
|
||||||
|
mainLoop: for (const child of children) {
|
||||||
|
if ($isElementNode(child)) {
|
||||||
|
for (const transformer of elementTransformers) {
|
||||||
|
const result = transformer.export(child, (_node) =>
|
||||||
|
exportChildren(_node, elementTransformers, textTransformersIndex, textMatchTransformers),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
output.push(result)
|
||||||
|
if (children.indexOf(child) !== children.length - 1) {
|
||||||
|
output.push('\n')
|
||||||
|
}
|
||||||
|
continue mainLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transformer of textMatchTransformers) {
|
||||||
|
const result = transformer.export(
|
||||||
|
child,
|
||||||
|
(parentNode) => exportChildren(parentNode, elementTransformers, textTransformersIndex, textMatchTransformers),
|
||||||
|
(textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
output.push(result)
|
||||||
|
continue mainLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isLineBreakNode(child)) {
|
||||||
|
output.push('\n')
|
||||||
|
} else if ($isTextNode(child)) {
|
||||||
|
output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex))
|
||||||
|
} else if ($isElementNode(child)) {
|
||||||
|
output.push(exportChildren(child, elementTransformers, textTransformersIndex, textMatchTransformers), '\n')
|
||||||
|
} else if ($isDecoratorNode(child)) {
|
||||||
|
output.push(child.getTextContent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTextFormat(node: TextNode, textContent: string, textTransformers: Array<TextFormatTransformer>): string {
|
||||||
|
// This function handles the case of a string looking like this: " foo "
|
||||||
|
// Where it would be invalid markdown to generate: "** foo **"
|
||||||
|
// We instead want to trim the whitespace out, apply formatting, and then
|
||||||
|
// bring the whitespace back. So our returned string looks like this: " **foo** "
|
||||||
|
const frozenString = textContent.trim()
|
||||||
|
let output = frozenString
|
||||||
|
|
||||||
|
const applied = new Set()
|
||||||
|
|
||||||
|
for (const transformer of textTransformers) {
|
||||||
|
const format = transformer.format[0]
|
||||||
|
const tag = transformer.tag
|
||||||
|
|
||||||
|
if (hasFormat(node, format) && !applied.has(format)) {
|
||||||
|
// Multiple tags might be used for the same format (*, _)
|
||||||
|
applied.add(format)
|
||||||
|
// Prevent adding opening tag is already opened by the previous sibling
|
||||||
|
const previousNode = getTextSibling(node, true)
|
||||||
|
|
||||||
|
if (!hasFormat(previousNode, format)) {
|
||||||
|
output = tag + output
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent adding closing tag if next sibling will do it
|
||||||
|
const nextNode = getTextSibling(node, false)
|
||||||
|
|
||||||
|
if (!hasFormat(nextNode, format)) {
|
||||||
|
output += tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace trimmed version of textContent ensuring surrounding whitespace is not modified
|
||||||
|
return textContent.replace(frozenString, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next or previous text sibling a text node, including cases
|
||||||
|
// when it's a child of inline element (e.g. link)
|
||||||
|
function getTextSibling(node: TextNode, backward: boolean): TextNode | null {
|
||||||
|
let sibling = backward ? node.getPreviousSibling() : node.getNextSibling()
|
||||||
|
|
||||||
|
if (!sibling) {
|
||||||
|
const parent = node.getParentOrThrow()
|
||||||
|
|
||||||
|
if (parent.isInline()) {
|
||||||
|
sibling = backward ? parent.getPreviousSibling() : parent.getNextSibling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (sibling) {
|
||||||
|
if ($isElementNode(sibling)) {
|
||||||
|
if (!sibling.isInline()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const descendant = backward ? sibling.getLastDescendant() : sibling.getFirstDescendant()
|
||||||
|
|
||||||
|
if ($isTextNode(descendant)) {
|
||||||
|
return descendant
|
||||||
|
} else {
|
||||||
|
sibling = backward ? sibling.getPreviousSibling() : sibling.getNextSibling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isTextNode(sibling)) {
|
||||||
|
return sibling
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isElementNode(sibling)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFormat(node: LexicalNode | null | undefined, format: TextFormatType): boolean {
|
||||||
|
return $isTextNode(node) && node.hasFormat(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $convertToMarkdownString(transformers: Array<Transformer> = TRANSFORMERS, node?: ElementNode): string {
|
||||||
|
const exportMarkdown = createMarkdownExport(transformers)
|
||||||
|
return exportMarkdown(node)
|
||||||
|
}
|
||||||
@@ -6,16 +6,13 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taken from https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownImport.ts
|
||||||
|
* but modified to allow keeping new lines when importing markdown.
|
||||||
|
*/
|
||||||
|
|
||||||
import { CodeNode, $createCodeNode } from '@lexical/code'
|
import { CodeNode, $createCodeNode } from '@lexical/code'
|
||||||
import {
|
import { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown'
|
||||||
ElementTransformer,
|
|
||||||
TextFormatTransformer,
|
|
||||||
TextMatchTransformer,
|
|
||||||
Transformer,
|
|
||||||
ELEMENT_TRANSFORMERS,
|
|
||||||
TEXT_FORMAT_TRANSFORMERS,
|
|
||||||
TEXT_MATCH_TRANSFORMERS,
|
|
||||||
} from '@lexical/markdown'
|
|
||||||
|
|
||||||
import { $isListItemNode, $isListNode } from '@lexical/list'
|
import { $isListItemNode, $isListNode } from '@lexical/list'
|
||||||
import { $isQuoteNode } from '@lexical/rich-text'
|
import { $isQuoteNode } from '@lexical/rich-text'
|
||||||
@@ -33,39 +30,10 @@ import {
|
|||||||
ElementNode,
|
ElementNode,
|
||||||
} from 'lexical'
|
} from 'lexical'
|
||||||
import { IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI } from '../Shared/environment'
|
import { IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI } from '../Shared/environment'
|
||||||
|
import { TRANSFORMERS, transformersByType } from './MarkdownImportExportUtils'
|
||||||
|
|
||||||
const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/
|
const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/
|
||||||
|
|
||||||
function indexBy<T>(list: Array<T>, callback: (arg0: T) => string): Readonly<Record<string, Array<T>>> {
|
|
||||||
const index: Record<string, Array<T>> = {}
|
|
||||||
|
|
||||||
for (const item of list) {
|
|
||||||
const key = callback(item)
|
|
||||||
|
|
||||||
if (index[key]) {
|
|
||||||
index[key].push(item)
|
|
||||||
} else {
|
|
||||||
index[key] = [item]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformersByType(transformers: Array<Transformer>): Readonly<{
|
|
||||||
element: Array<ElementTransformer>
|
|
||||||
textFormat: Array<TextFormatTransformer>
|
|
||||||
textMatch: Array<TextMatchTransformer>
|
|
||||||
}> {
|
|
||||||
const byType = indexBy(transformers, (t) => t.type)
|
|
||||||
|
|
||||||
return {
|
|
||||||
element: (byType.element || []) as Array<ElementTransformer>,
|
|
||||||
textFormat: (byType['text-format'] || []) as Array<TextFormatTransformer>,
|
|
||||||
textMatch: (byType['text-match'] || []) as Array<TextMatchTransformer>,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/
|
const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/
|
||||||
const CODE_BLOCK_REG_EXP = /^```(\w{1,10})?\s?$/
|
const CODE_BLOCK_REG_EXP = /^```(\w{1,10})?\s?$/
|
||||||
type TextFormatTransformersIndex = Readonly<{
|
type TextFormatTransformersIndex = Readonly<{
|
||||||
@@ -392,12 +360,6 @@ function createTextFormatTransformersIndex(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSFORMERS: Array<Transformer> = [
|
|
||||||
...ELEMENT_TRANSFORMERS,
|
|
||||||
...TEXT_FORMAT_TRANSFORMERS,
|
|
||||||
...TEXT_MATCH_TRANSFORMERS,
|
|
||||||
]
|
|
||||||
|
|
||||||
export function $convertFromMarkdownString(
|
export function $convertFromMarkdownString(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
transformers: Array<Transformer> = TRANSFORMERS,
|
transformers: Array<Transformer> = TRANSFORMERS,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
ElementTransformer,
|
||||||
|
TextFormatTransformer,
|
||||||
|
TextMatchTransformer,
|
||||||
|
Transformer,
|
||||||
|
ELEMENT_TRANSFORMERS,
|
||||||
|
TEXT_FORMAT_TRANSFORMERS,
|
||||||
|
TEXT_MATCH_TRANSFORMERS,
|
||||||
|
} from '@lexical/markdown'
|
||||||
|
|
||||||
|
function indexBy<T>(list: Array<T>, callback: (arg0: T) => string): Readonly<Record<string, Array<T>>> {
|
||||||
|
const index: Record<string, Array<T>> = {}
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
const key = callback(item)
|
||||||
|
|
||||||
|
if (index[key]) {
|
||||||
|
index[key].push(item)
|
||||||
|
} else {
|
||||||
|
index[key] = [item]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformersByType(transformers: Array<Transformer>): Readonly<{
|
||||||
|
element: Array<ElementTransformer>
|
||||||
|
textFormat: Array<TextFormatTransformer>
|
||||||
|
textMatch: Array<TextMatchTransformer>
|
||||||
|
}> {
|
||||||
|
const byType = indexBy(transformers, (t) => t.type)
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: (byType.element || []) as Array<ElementTransformer>,
|
||||||
|
textFormat: (byType['text-format'] || []) as Array<TextFormatTransformer>,
|
||||||
|
textMatch: (byType['text-match'] || []) as Array<TextMatchTransformer>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSFORMERS: Array<Transformer> = [
|
||||||
|
...ELEMENT_TRANSFORMERS,
|
||||||
|
...TEXT_FORMAT_TRANSFORMERS,
|
||||||
|
...TEXT_MATCH_TRANSFORMERS,
|
||||||
|
]
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createHeadlessEditor } from '@lexical/headless'
|
import { createHeadlessEditor } from '@lexical/headless'
|
||||||
import { $convertToMarkdownString } from '@lexical/markdown'
|
|
||||||
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
|
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
|
||||||
import {
|
import {
|
||||||
$createParagraphNode,
|
$createParagraphNode,
|
||||||
@@ -18,6 +17,7 @@ import { FileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileNode'
|
|||||||
import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode'
|
import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode'
|
||||||
import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode'
|
import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode'
|
||||||
import { $convertFromMarkdownString } from '../Lexical/Utils/MarkdownImport'
|
import { $convertFromMarkdownString } from '../Lexical/Utils/MarkdownImport'
|
||||||
|
import { $convertToMarkdownString } from '../Lexical/Utils/MarkdownExport'
|
||||||
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||||
private importEditor: LexicalEditor
|
private importEditor: LexicalEditor
|
||||||
private exportEditor: LexicalEditor
|
private exportEditor: LexicalEditor
|
||||||
|
|||||||
Reference in New Issue
Block a user