UNPKG

@terrible-lexical/markdown

Version:

This package contains Markdown helpers and functionality for Lexical.

374 lines (344 loc) 10.2 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 type {ListType} from '@terrible-lexical/list/src'; import type {HeadingTagType} from '@terrible-lexical/rich-text/src'; import {$createCodeNode, $isCodeNode, CodeNode} from '@terrible-lexical/code/src'; import {$createLinkNode, $isLinkNode, LinkNode} from '@terrible-lexical/link/src'; import { $createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode, ListNode, } from '@terrible-lexical/list/src'; import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, HeadingNode, QuoteNode, } from '@terrible-lexical/rich-text/src'; import { $createLineBreakNode, $createTextNode, $isTextNode, ElementNode, Klass, LexicalNode, TextFormatType, TextNode, } from 'terrible-lexical'; export type Transformer = | ElementTransformer | TextFormatTransformer | TextMatchTransformer; export type ElementTransformer = { dependencies: Array<Klass<LexicalNode>>; export: ( node: LexicalNode, // eslint-disable-next-line no-shadow traverseChildren: (node: ElementNode) => string, ) => string | null; regExp: RegExp; replace: ( parentNode: ElementNode, children: Array<LexicalNode>, match: Array<string>, isImport: boolean, ) => void; type: 'element'; }; export type TextFormatTransformer = Readonly<{ format: ReadonlyArray<TextFormatType>; tag: string; intraword?: boolean; type: 'text-format'; }>; export type TextMatchTransformer = Readonly<{ dependencies: Array<Klass<LexicalNode>>; export: ( node: LexicalNode, // eslint-disable-next-line no-shadow exportChildren: (node: ElementNode) => string, // eslint-disable-next-line no-shadow exportFormat: (node: TextNode, textContent: string) => string, ) => string | null; importRegExp: RegExp; regExp: RegExp; replace: (node: TextNode, match: RegExpMatchArray) => void; trigger: string; type: 'text-match'; }>; const createBlockNode = ( createNode: (match: Array<string>) => ElementNode, ): ElementTransformer['replace'] => { return (parentNode, children, match) => { const node = createNode(match); node.append(...children); parentNode.replace(node); node.select(0, 0); }; }; // Amount of spaces that define indentation level // TODO: should be an option const LIST_INDENT_SIZE = 4; const listReplace = (listType: ListType): ElementTransformer['replace'] => { return (parentNode, children, match) => { const previousNode = parentNode.getPreviousSibling(); const nextNode = parentNode.getNextSibling(); const listItem = $createListItemNode( listType === 'check' ? match[3] === 'x' : undefined, ); if ($isListNode(nextNode) && nextNode.getListType() === listType) { 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); } parentNode.remove(); } else if ( $isListNode(previousNode) && previousNode.getListType() === listType ) { previousNode.append(listItem); parentNode.remove(); } else { const list = $createListNode( listType, listType === 'number' ? Number(match[2]) : undefined, ); list.append(listItem); parentNode.replace(list); } listItem.append(...children); listItem.select(0, 0); const indent = Math.floor(match[1].length / LIST_INDENT_SIZE); if (indent) { listItem.setIndent(indent); } }; }; const listExport = ( listNode: ListNode, exportChildren: (node: ElementNode) => string, depth: number, ): string => { 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)) { output.push(listExport(firstChild, exportChildren, depth + 1)); continue; } } const indent = ' '.repeat(depth * LIST_INDENT_SIZE); const listType = listNode.getListType(); const prefix = listType === 'number' ? `${listNode.getStart() + index}. ` : listType === 'check' ? `- [${listItemNode.getChecked() ? 'x' : ' '}] ` : '- '; output.push(indent + prefix + exportChildren(listItemNode)); index++; } } return output.join('\n'); }; export const HEADING: ElementTransformer = { dependencies: [HeadingNode], export: (node, exportChildren) => { if (!$isHeadingNode(node)) { return null; } const level = Number(node.getTag().slice(1)); return '#'.repeat(level) + ' ' + exportChildren(node); }, regExp: /^(#{1,6})\s/, replace: createBlockNode((match) => { const tag = ('h' + match[1].length) as HeadingTagType; return $createHeadingNode(tag); }), type: 'element', }; export const QUOTE: ElementTransformer = { 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: /^>\s/, replace: (parentNode, children, _match, isImport) => { if (isImport) { const previousNode = parentNode.getPreviousSibling(); if ($isQuoteNode(previousNode)) { previousNode.splice(previousNode.getChildrenSize(), 0, [ $createLineBreakNode(), ...children, ]); previousNode.select(0, 0); parentNode.remove(); return; } } const node = $createQuoteNode(); node.append(...children); parentNode.replace(node); node.select(0, 0); }, type: 'element', }; export const CODE: ElementTransformer = { dependencies: [CodeNode], export: (node: LexicalNode) => { if (!$isCodeNode(node)) { return null; } const textContent = node.getTextContent(); return ( '```' + (node.getLanguage() || '') + (textContent ? '\n' + textContent : '') + '\n' + '```' ); }, regExp: /^```(\w{1,10})?\s/, replace: createBlockNode((match) => { return $createCodeNode(match ? match[1] : undefined); }), type: 'element', }; export const UNORDERED_LIST: ElementTransformer = { dependencies: [ListNode, ListItemNode], export: (node, exportChildren) => { return $isListNode(node) ? listExport(node, exportChildren, 0) : null; }, regExp: /^(\s*)[-*+]\s/, replace: listReplace('bullet'), type: 'element', }; export const CHECK_LIST: ElementTransformer = { dependencies: [ListNode, ListItemNode], export: (node, exportChildren) => { return $isListNode(node) ? listExport(node, exportChildren, 0) : null; }, regExp: /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i, replace: listReplace('check'), type: 'element', }; export const ORDERED_LIST: ElementTransformer = { dependencies: [ListNode, ListItemNode], export: (node, exportChildren) => { return $isListNode(node) ? listExport(node, exportChildren, 0) : null; }, regExp: /^(\s*)(\d{1,})\.\s/, replace: listReplace('number'), type: 'element', }; export const INLINE_CODE: TextFormatTransformer = { format: ['code'], tag: '`', type: 'text-format', }; export const HIGHLIGHT: TextFormatTransformer = { format: ['highlight'], tag: '==', type: 'text-format', }; export const BOLD_ITALIC_STAR: TextFormatTransformer = { format: ['bold', 'italic'], tag: '***', type: 'text-format', }; export const BOLD_ITALIC_UNDERSCORE: TextFormatTransformer = { format: ['bold', 'italic'], intraword: false, tag: '___', type: 'text-format', }; export const BOLD_STAR: TextFormatTransformer = { format: ['bold'], tag: '**', type: 'text-format', }; export const BOLD_UNDERSCORE: TextFormatTransformer = { format: ['bold'], intraword: false, tag: '__', type: 'text-format', }; export const STRIKETHROUGH: TextFormatTransformer = { format: ['strikethrough'], tag: '~~', type: 'text-format', }; export const ITALIC_STAR: TextFormatTransformer = { format: ['italic'], tag: '*', type: 'text-format', }; export const ITALIC_UNDERSCORE: TextFormatTransformer = { 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 _) export const LINK: TextMatchTransformer = { dependencies: [LinkNode], export: (node, exportChildren, exportFormat) => { if (!$isLinkNode(node)) { return null; } const title = node.getTitle(); const linkContent = title ? `[${node.getTextContent()}](${node.getURL()} "${title}")` : `[${node.getTextContent()}](${node.getURL()})`; const firstChild = node.getFirstChild(); // Add text styles only if link has single text node inside. If it's more // then one we ignore it as markdown does not support nested styles for links if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) { return exportFormat(firstChild, linkContent); } else { return linkContent; } }, importRegExp: /(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/, regExp: /(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/, replace: (textNode, match) => { const [, linkText, linkUrl, linkTitle] = match; const linkNode = $createLinkNode(linkUrl, {title: linkTitle}); const linkTextNode = $createTextNode(linkText); linkTextNode.setFormat(textNode.getFormat()); linkNode.append(linkTextNode); textNode.replace(linkNode); }, trigger: ')', type: 'text-match', };