rittenhop-ghost/versions/5.94.2/node_modules/@tryghost/kg-converters/lib/lexical-to-mobiledoc.js

398 lines
13 KiB
JavaScript
Raw Normal View History

const MOBILEDOC_VERSION = '0.3.1';
const GHOST_VERSION = '4.0';
const BLANK_DOC = {
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 = {
codeblock: 'code',
horizontalrule: 'hr'
};
const CARD_PROPERTY_MAP = {
embed: {
embedType: 'type'
}
};
export function lexicalToMobiledoc(serializedLexical) {
if (serializedLexical === null || serializedLexical === undefined || serializedLexical === '') {
return JSON.stringify(BLANK_DOC);
}
const lexical = JSON.parse(serializedLexical);
if (!lexical.root) {
return JSON.stringify(BLANK_DOC);
}
const mobiledoc = buildEmptyDoc();
lexical.root.children.forEach(child => addRootChild(child, mobiledoc));
return JSON.stringify(mobiledoc);
}
/* internal functions ------------------------------------------------------- */
function buildEmptyDoc() {
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(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[cardName]) {
cardName = CARD_NAME_MAP[cardName];
}
// don't include type in the payload
delete child.type;
// rename any properties to match mobiledoc
if (CARD_PROPERTY_MAP[cardType]) {
const map = CARD_PROPERTY_MAP[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);
}