const MOBILEDOC_VERSION = '0.3.1'; const GHOST_VERSION = '4.0'; const BLANK_DOC$1 = { version: MOBILEDOC_VERSION, ghostVersion: GHOST_VERSION, markups: [], atoms: [], cards: [], sections: [[1, 'p', [[0, [], 0, '']]]] }; const MD_TEXT_SECTION = 1; const MD_LIST_SECTION = 3; const MD_CARD_SECTION = 10; const MD_TEXT_MARKER = 0; const MD_ATOM_MARKER = 1; const L_IS_BOLD = 1; const L_IS_ITALIC = 1 << 1; const L_IS_STRIKETHROUGH = 1 << 2; const L_IS_UNDERLINE = 1 << 3; const L_IS_CODE = 1 << 4; const L_IS_SUBSCRIPT = 1 << 5; const L_IS_SUPERSCRIPT = 1 << 6; const L_FORMAT_MAP = new Map([[L_IS_BOLD, 'strong'], [L_IS_ITALIC, 'em'], [L_IS_STRIKETHROUGH, 's'], [L_IS_UNDERLINE, 'u'], [L_IS_CODE, 'code'], [L_IS_SUBSCRIPT, 'sub'], [L_IS_SUPERSCRIPT, 'sup']]); const HEADING_TYPES = ['heading', 'extended-heading']; const TEXT_TYPES = ['text', 'extended-text']; // TODO: Feels a little too explicit as it will need updating every time we add a new card. // // One alternative is to use a list of all built-in Lexical types and assume that anything // not listed is a card but that feels more dangerous. // // Another alternative is to grab the list of cards from kg-default-nodes but that's creating // more inter-dependencies that makes development setup tricky. const KNOWN_CARDS = ['audio', 'bookmark', 'button', 'callout', 'codeblock', 'email-cta', 'email', 'embed', 'file', 'gallery', 'header', 'horizontalrule', 'html', 'image', 'markdown', 'paywall', 'product', 'signup', 'toggle', 'video']; const CARD_NAME_MAP$1 = { codeblock: 'code', horizontalrule: 'hr' }; const CARD_PROPERTY_MAP$1 = { embed: { embedType: 'type' } }; function lexicalToMobiledoc(serializedLexical) { if (serializedLexical === null || serializedLexical === undefined || serializedLexical === '') { return JSON.stringify(BLANK_DOC$1); } const lexical = JSON.parse(serializedLexical); if (!lexical.root) { return JSON.stringify(BLANK_DOC$1); } const mobiledoc = buildEmptyDoc$1(); lexical.root.children.forEach(child => addRootChild$1(child, mobiledoc)); return JSON.stringify(mobiledoc); } /* internal functions ------------------------------------------------------- */ function buildEmptyDoc$1() { return { version: MOBILEDOC_VERSION, ghostVersion: GHOST_VERSION, atoms: [], cards: [], markups: [], sections: [] }; } function getOrSetMarkupIndex(markup, mobiledoc) { let index = mobiledoc.markups.findIndex(m => m[0] === markup); if (index === -1) { mobiledoc.markups.push([markup]); index = mobiledoc.markups.length - 1; } return index; } function getOrSetAtomIndex(atom, mobiledoc) { let index = mobiledoc.atoms.findIndex(m => m[0] === atom); if (index === -1) { mobiledoc.atoms.push(atom); index = mobiledoc.atoms.length - 1; } return index; } function addRootChild$1(child, mobiledoc) { if (child.type === 'paragraph') { addTextSection(child, mobiledoc); } if (HEADING_TYPES.includes(child.type)) { addTextSection(child, mobiledoc, child.tag); } if (child.type === 'quote') { addTextSection(child, mobiledoc, 'blockquote'); } if (child.type === 'aside') { addTextSection(child, mobiledoc, 'aside'); } if (child.type === 'list') { addListSection(child, mobiledoc, child.tag); } if (KNOWN_CARDS.includes(child.type)) { addCardSection(child, mobiledoc); } } function addTextSection(childWithFormats, mobiledoc, tagName = 'p') { const markers = buildMarkers(childWithFormats, mobiledoc); const section = [MD_TEXT_SECTION, tagName, markers]; mobiledoc.sections.push(section); } function addListSection(listChild, mobiledoc, tagName = 'ul') { const listItems = buildListItems(listChild, mobiledoc); const section = [MD_LIST_SECTION, tagName, listItems]; mobiledoc.sections.push(section); } function buildListItems(listRoot, mobiledoc) { const listItems = []; flattenListChildren(listRoot); listRoot.children.forEach(listItemChild => { if (listItemChild.type === 'listitem') { const markers = buildMarkers(listItemChild, mobiledoc); listItems.push(markers); } }); return listItems; } function flattenListChildren(listRoot) { const flatListItems = []; function traverse(item) { item.children?.forEach(child => { child.children?.forEach(grandchild => { if (grandchild.type === 'list') { traverse(grandchild); child.children.splice(child.children.indexOf(grandchild), 1); } }); if (child.type === 'listitem' && child.children.length) { flatListItems.push(child); } }); } traverse(listRoot); listRoot.children = flatListItems; } function buildMarkers(childWithFormats, mobiledoc) { const markers = []; if (!childWithFormats.children.length) { markers.push([MD_TEXT_MARKER, [], 0, '']); } else { // mobiledoc tracks opened/closed formats across markers whereas lexical // lists all formats for each marker so we need to manually track open formats let openMarkups = []; // markup: a specific format, or tag name+attributes // marker: a piece of text with 0 or more markups childWithFormats.children.forEach((child, childIndex) => { if (TEXT_TYPES.includes(child.type)) { if (child.format !== 0) { // text child has formats, track which are new and which have closed const openedFormats = []; const childFormats = readFormat(child.format); let closedFormatCount = 0; childFormats.forEach(format => { if (!openMarkups.includes(format)) { openMarkups.push(format); openedFormats.push(format); } }); // mobiledoc will immediately close any formats if the next section doesn't use them or it's not a text section if (!childWithFormats.children[childIndex + 1] || !TEXT_TYPES.includes(childWithFormats.children[childIndex + 1].type)) { // no more children, close all formats closedFormatCount = openMarkups.length; openMarkups = []; } else { const nextChild = childWithFormats.children[childIndex + 1]; const nextFormats = readFormat(nextChild.format); const firstMissingFormatIndex = openMarkups.findIndex(format => !nextFormats.includes(format)); if (firstMissingFormatIndex !== -1) { const formatsToClose = openMarkups.slice(firstMissingFormatIndex); closedFormatCount = formatsToClose.length; openMarkups = openMarkups.slice(0, firstMissingFormatIndex); } } const markupIndexes = openedFormats.map(format => getOrSetMarkupIndex(format, mobiledoc)); markers.push([MD_TEXT_MARKER, markupIndexes, closedFormatCount, child.text]); } else { // text child has no formats so we close all formats in mobiledoc let closedFormatCount = openMarkups.length; openMarkups = []; markers.push([MD_TEXT_MARKER, [], closedFormatCount, child.text]); } } if (child.type === 'link') { const linkMarkup = ['a', ['href', child.url]]; const linkMarkupIndex = mobiledoc.markups.push(linkMarkup) - 1; child.children.forEach((linkChild, linkChildIndex) => { if (linkChild.format !== 0) { const openedMarkupIndexes = []; const openedFormats = []; // first child of a link opens the link markup if (linkChildIndex === 0) { openMarkups.push(linkMarkup); openedMarkupIndexes.push(linkMarkupIndex); } // text child has formats, track which are new and which have closed const childFormats = readFormat(linkChild.format); let closedMarkupCount = 0; childFormats.forEach(format => { if (!openMarkups.includes(format)) { openMarkups.push(format); openedFormats.push(format); } }); // mobiledoc will immediately close any formats if the next section doesn't use them if (!child.children[linkChildIndex + 1]) { // last child of a link closes all markups closedMarkupCount = openMarkups.length; openMarkups = []; } else { const nextChild = child.children[linkChildIndex + 1]; const nextFormats = readFormat(nextChild.format); const firstMissingFormatIndex = openMarkups.findIndex(markup => { const markupIsLink = JSON.stringify(markup) === JSON.stringify(linkMarkup); return !markupIsLink && !nextFormats.includes(markup); }); if (firstMissingFormatIndex !== -1) { const formatsToClose = openMarkups.slice(firstMissingFormatIndex); closedMarkupCount = formatsToClose.length; openMarkups = openMarkups.slice(0, firstMissingFormatIndex); } } openedMarkupIndexes.push(...openedFormats.map(format => getOrSetMarkupIndex(format, mobiledoc))); markers.push([MD_TEXT_MARKER, openedMarkupIndexes, closedMarkupCount, linkChild.text]); } else { const openedMarkupIndexes = []; // first child of a link opens the link markup if (linkChildIndex === 0) { openMarkups.push(linkMarkup); openedMarkupIndexes.push(linkMarkupIndex); } let closedMarkupCount = openMarkups.length - 1; // don't close the link markup, just the others // last child of a link closes all markups if (!child.children[linkChildIndex + 1]) { closedMarkupCount += 1; // close the link markup openMarkups = []; } markers.push([MD_TEXT_MARKER, openedMarkupIndexes, closedMarkupCount, linkChild.text]); } }); } if (child.type === 'linebreak') { const atom = ['soft-return', '', {}]; const atomIndex = getOrSetAtomIndex(atom, mobiledoc); markers.push([MD_ATOM_MARKER, [], 0, atomIndex]); } }); } return markers; } // Lexical stores formats as a bitmask, so we need to read the bitmask to // determine which formats are present function readFormat(format) { const formats = []; L_FORMAT_MAP.forEach((value, key) => { if ((format & key) !== 0) { formats.push(value); } }); return formats; } function addCardSection(child, mobiledoc) { const cardType = child.type; let cardName = child.type; // rename card if there's a difference between lexical/mobiledoc if (CARD_NAME_MAP$1[cardName]) { cardName = CARD_NAME_MAP$1[cardName]; } // don't include type in the payload delete child.type; // rename any properties to match mobiledoc if (CARD_PROPERTY_MAP$1[cardType]) { const map = CARD_PROPERTY_MAP$1[cardType]; for (const [key, value] of Object.entries(map)) { child[value] = child[key]; delete child[key]; } } const card = [cardName, child]; mobiledoc.cards.push(card); const cardIndex = mobiledoc.cards.length - 1; const section = [MD_CARD_SECTION, cardIndex]; mobiledoc.sections.push(section); } const BLANK_DOC = { root: { children: [], direction: null, format: '', indent: 0, type: 'root', version: 1 } }; const TAG_TO_LEXICAL_NODE = { p: { type: 'paragraph' }, h1: { type: 'heading', tag: 'h1' }, h2: { type: 'heading', tag: 'h2' }, h3: { type: 'heading', tag: 'h3' }, h4: { type: 'heading', tag: 'h4' }, h5: { type: 'heading', tag: 'h5' }, h6: { type: 'heading', tag: 'h6' }, blockquote: { type: 'quote' }, aside: { type: 'aside' }, a: { type: 'link', rel: null, target: null, title: null, url: null } }; const ATOM_TO_LEXICAL_NODE = { 'soft-return': { type: 'linebreak', version: 1 } }; const MARKUP_TO_FORMAT = { strong: 1, b: 1, em: 1 << 1, i: 1 << 1, s: 1 << 2, u: 1 << 3, code: 1 << 4, sub: 1 << 5, sup: 1 << 6 }; const CARD_NAME_MAP = { code: 'codeblock', hr: 'horizontalrule' }; const CARD_PROPERTY_MAP = { embed: { type: 'embedType' } }; const CARD_FIXES_MAP = { callout: payload => { if (payload.backgroundColor && !payload.backgroundColor.match(/^[a-zA-Z\d-]+$/)) { payload.backgroundColor = 'white'; } return payload; } }; function mobiledocToLexical(serializedMobiledoc) { if (serializedMobiledoc === null || serializedMobiledoc === undefined || serializedMobiledoc === '') { return JSON.stringify(BLANK_DOC); } const mobiledoc = JSON.parse(serializedMobiledoc); if (!mobiledoc.sections) { return JSON.stringify(BLANK_DOC); } const lexical = buildEmptyDoc(); mobiledoc.sections.forEach(child => addRootChild(child, mobiledoc, lexical)); return JSON.stringify(lexical); } /* internal functions ------------------------------------------------------- */ function buildEmptyDoc() { return { root: { children: [], direction: null, format: '', indent: 0, type: 'root', version: 1 } }; } function addRootChild(child, mobiledoc, lexical) { const sectionTypeIdentifier = child[0]; if (sectionTypeIdentifier === 1) { // Markup (text) section const lexicalChild = convertMarkupSectionToLexical(child, mobiledoc); lexical.root.children.push(lexicalChild); // Set direction to ltr if there is any text // Otherwise direction should be null // Not sure if this is necessary: // if we don't plan to support RTL, we could just set 'ltr' in all cases and ignore null if (lexicalChild.children?.length > 0) { lexical.root.direction = 'ltr'; } } else if (sectionTypeIdentifier === 2) ; else if (sectionTypeIdentifier === 3) { // List section const lexicalChild = convertListSectionToLexical(child, mobiledoc); lexical.root.children.push(lexicalChild); lexical.root.direction = 'ltr'; // mobiledoc only supports LTR } else if (sectionTypeIdentifier === 10) { // Card section const lexicalChild = convertCardSectionToLexical(child, mobiledoc); lexical.root.children.push(lexicalChild); } } function convertMarkupSectionToLexical(section, mobiledoc) { const tagName = section[1]; // e.g. 'p' const markers = section[2]; // e.g. [[0, [0], 0, "Hello world"]] // Create an empty Lexical node from the tag name // We will add nodes to the children array later const lexicalNode = createEmptyLexicalNode(tagName); populateLexicalNodeWithMarkers(lexicalNode, markers, mobiledoc); return lexicalNode; } function populateLexicalNodeWithMarkers(lexicalNode, markers, mobiledoc) { const markups = mobiledoc.markups; const atoms = mobiledoc.atoms; // Initiate some variables before looping over all the markers let openMarkups = []; // tracks which markup tags are open for the current marker let linkNode = undefined; // tracks current link node or undefined if no a tag is open let href = undefined; // tracks the href for the current link node or undefined if no a tag is open let rel = undefined; //tracks the rel attribute for the current link node or undefined if no a tag is open let openLinkMarkup = false; // tracks whether the current node is a link node // loop over markers and convert each one to lexical for (let i = 0; i < markers.length; i++) { // grab the attributes from the current marker const [textTypeIdentifier, openMarkupsIndexes, numberOfClosedMarkups, value] = markers[i]; // Markers are either text (markup) or atoms const markerType = textTypeIdentifier === 0 ? 'markup' : 'atom'; // If the current marker is an atom, convert the atom to Lexical and add to the node if (markerType === 'atom') { const atom = atoms[value]; const atomName = atom[0]; const childNode = ATOM_TO_LEXICAL_NODE[atomName]; embedChildNode(lexicalNode, childNode); continue; } // calculate which markups are open for the current marker openMarkupsIndexes.forEach(markupIndex => { const markup = markups[markupIndex]; // Extract the href from the markup if it's a link if (markup[0] === 'a') { openLinkMarkup = true; if (markup[1] && markup[1][0] === 'href') { href = markup[1][1]; } if (markup[1] && markup[1][2] === 'rel') { rel = markup[1][3]; } } // Add the markup to the list of open markups openMarkups.push(markup); }); if (value !== undefined) { // Convert the open markups to a bitmask compatible with Lexical const format = convertMarkupTagsToLexicalFormatBitmask(openMarkups); // If there is an open link tag, add the text to the link node // Otherwise add the text to the parent node if (openLinkMarkup) { // link is open // Create an empty link node if it doesn't exist already linkNode = linkNode !== undefined ? linkNode : createEmptyLexicalNode('a', { url: href, rel: rel || null }); // Create a text node and add it to the link node const textNode = createTextNode(value, format); embedChildNode(linkNode, textNode); } else { const textNode = createTextNode(value, format); embedChildNode(lexicalNode, textNode); } } // Close any markups that are closed after the current marker // Remove any closed markups from openMarkups list for (let j = 0; j < numberOfClosedMarkups; j++) { // Remove the most recently opened markup from the list of open markups const markup = openMarkups.pop(); // If we're closing a link tag, add the linkNode to the node // Reset href and linkNode for the next markup if (markup && markup[0] === 'a') { embedChildNode(lexicalNode, linkNode); openLinkMarkup = false; href = undefined; linkNode = undefined; } } } } // Creates a text node from the given text and format function createTextNode(text, format) { return { detail: 0, format: format, mode: 'normal', style: '', text: text, type: 'text', version: 1 }; } // Creates an empty Lexical node from the given tag name and additional attributes function createEmptyLexicalNode(tagName, attributes = {}) { const nodeParams = TAG_TO_LEXICAL_NODE[tagName]; const node = { children: [], direction: 'ltr', format: '', indent: 0, ...nodeParams, ...attributes, version: 1 }; return node; } // Adds a child node to a parent node function embedChildNode(parentNode, childNode) { // If there is no child node, do nothing if (!childNode) { return; } // Add textNode to node's children parentNode.children.push(childNode); // If there is any text (e.g. not a blank text node), set the direction to ltr if (childNode && 'text' in childNode && childNode.text) { parentNode.direction = 'ltr'; } } // Lexical stores formats as a bitmask // Mobiledoc stores formats as a list of open markup tags // This function converts a list of open tags to a bitmask compatible with lexical function convertMarkupTagsToLexicalFormatBitmask(tags) { let format = 0; tags.forEach(tag => { if (tag in MARKUP_TO_FORMAT) { format = format | MARKUP_TO_FORMAT[tag]; } }); return format; } function convertListSectionToLexical(child, mobiledoc) { const tag = child[1]; const listType = tag === 'ul' ? 'bullet' : 'number'; const listNode = createEmptyLexicalNode(tag, { tag, type: 'list', listType, start: 1, direction: 'ltr' }); child[2]?.forEach((listItem, i) => { const listItemNode = createEmptyLexicalNode('li', { type: 'listitem', value: i + 1, direction: 'ltr' }); populateLexicalNodeWithMarkers(listItemNode, listItem, mobiledoc); listNode.children.push(listItemNode); }); return listNode; } function convertCardSectionToLexical(child, mobiledoc) { let [cardName, payload] = mobiledoc.cards[child[1]]; // rename card if there's a difference between mobiledoc and lexical cardName = CARD_NAME_MAP[cardName] || cardName; // rename any properties to match lexical if (CARD_PROPERTY_MAP[cardName]) { const map = CARD_PROPERTY_MAP[cardName]; for (const [oldName, newName] of Object.entries(map)) { payload[newName] = payload[oldName]; delete payload[oldName]; } } // run any payload fixes if (CARD_FIXES_MAP[cardName]) { payload = CARD_FIXES_MAP[cardName](payload); } delete payload.type; const decoratorNode = { type: cardName, ...payload }; return decoratorNode; } export { lexicalToMobiledoc, mobiledocToLexical }; //# sourceMappingURL=kg-converters.js.map