323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
|
/**
|
||
|
* Copied from:
|
||
|
* https://github.com/TryGhost/Ghost-Admin/blob/1f3d77d7230dd47a7eb5f38b90dfa510b2a16801/lib/koenig-editor/addon/options/parser-plugins.js
|
||
|
* Which makes use of:
|
||
|
* https://github.com/TryGhost/Ghost-Admin/blob/1f3d77d7230dd47a7eb5f38b90dfa510b2a16801/lib/koenig-editor/addon/helpers/clean-basic-html.js
|
||
|
*
|
||
|
* These functions are used to proces nodes during parsing from DOM -> mobiledoc
|
||
|
*/
|
||
|
|
||
|
import cleanBasicHtml from '@tryghost/kg-clean-basic-html';
|
||
|
|
||
|
import * as audioCard from './cards/audio';
|
||
|
import * as buttonCard from './cards/button';
|
||
|
import * as embedCard from './cards/embed';
|
||
|
import * as fileCard from './cards/file';
|
||
|
import * as headerCard from './cards/header';
|
||
|
import * as htmlCard from './cards/html';
|
||
|
import * as imageCard from './cards/image';
|
||
|
import * as productCard from './cards/product';
|
||
|
import * as softReturn from './cards/soft-return';
|
||
|
import * as videoCard from './cards/video';
|
||
|
import * as galleryCard from './cards/gallery';
|
||
|
|
||
|
export function createParserPlugins(_options = {}) {
|
||
|
const defaults = {};
|
||
|
const options = Object.assign({}, defaults, _options);
|
||
|
|
||
|
if (!options.createDocument) {
|
||
|
const Parser = (typeof DOMParser !== 'undefined' && DOMParser) || (typeof window !== 'undefined' && window.DOMParser);
|
||
|
|
||
|
if (!Parser) {
|
||
|
// eslint-disable-next-line ghost/ghost-custom/no-native-error
|
||
|
throw new Error('createParserPlugins() must be passed a `createDocument` function as an option when used in a non-browser environment');
|
||
|
}
|
||
|
|
||
|
options.createDocument = function (html) {
|
||
|
const parser = new Parser();
|
||
|
return parser.parseFromString(html, 'text/html');
|
||
|
};
|
||
|
}
|
||
|
|
||
|
options.cleanBasicHtml = function (html) {
|
||
|
return cleanBasicHtml(html, options);
|
||
|
};
|
||
|
|
||
|
// HELPERS -----------------------------------------------------------------
|
||
|
|
||
|
function _readFigCaptionFromNode(node, payload, selector = 'figcaption') {
|
||
|
let figcaptions = Array.from(node.querySelectorAll(selector));
|
||
|
|
||
|
if (figcaptions.length) {
|
||
|
figcaptions.forEach((caption) => {
|
||
|
let cleanHtml = options.cleanBasicHtml(caption.innerHTML);
|
||
|
payload.caption = payload.caption ? `${payload.caption} / ${cleanHtml}` : cleanHtml;
|
||
|
caption.remove(); // cleanup this processed element
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// PLUGINS -----------------------------------------------------------------
|
||
|
|
||
|
function kgCalloutCardToCard(node, builder, {addSection, nodeFinished}) {
|
||
|
if (node.nodeType !== 1 || !node.classList.contains('kg-callout-card')) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const emojiNode = node.querySelector('.kg-callout-emoji');
|
||
|
const htmlNode = node.querySelector('.kg-callout-text');
|
||
|
|
||
|
const backgroundColor = node.style.backgroundColor || '#F1F3F4';
|
||
|
|
||
|
let calloutEmoji = '';
|
||
|
if (emojiNode) {
|
||
|
calloutEmoji = emojiNode?.textContent;
|
||
|
if (calloutEmoji) {
|
||
|
calloutEmoji = calloutEmoji.trim();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let calloutText = htmlNode?.innerHTML || '';
|
||
|
|
||
|
const payload = {
|
||
|
calloutEmoji,
|
||
|
calloutText,
|
||
|
backgroundColor
|
||
|
};
|
||
|
|
||
|
const cardSection = builder.createCardSection('callout', payload);
|
||
|
addSection(cardSection);
|
||
|
nodeFinished();
|
||
|
}
|
||
|
|
||
|
function kgToggleCardToCard(node, builder, {addSection, nodeFinished}) {
|
||
|
if (node.nodeType !== 1 || !node.classList.contains('kg-toggle-card')) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const headingNode = node.querySelector('.kg-toggle-heading-text');
|
||
|
const contentNode = node.querySelector('.kg-toggle-content');
|
||
|
|
||
|
let toggleHeading = headingNode.innerHTML;
|
||
|
let toggleContent = contentNode.innerText;
|
||
|
|
||
|
const payload = {
|
||
|
heading: toggleHeading,
|
||
|
content: toggleContent
|
||
|
};
|
||
|
|
||
|
const cardSection = builder.createCardSection('toggle', payload);
|
||
|
addSection(cardSection);
|
||
|
nodeFinished();
|
||
|
}
|
||
|
|
||
|
// leading newlines in text nodes will add a space to the beginning of the text
|
||
|
// which doesn't render correctly if we're replacing <br> with SoftReturn atoms
|
||
|
// after parsing text as markdown to html
|
||
|
function removeLeadingNewline(node) {
|
||
|
if (node.nodeType !== 3 || node.nodeName !== '#text') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
node.nodeValue = node.nodeValue.replace(/^\n/, '');
|
||
|
}
|
||
|
|
||
|
function hrToCard(node, builder, {addSection, nodeFinished}) {
|
||
|
if (node.nodeType !== 1 || node.tagName !== 'HR') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let cardSection = builder.createCardSection('hr');
|
||
|
addSection(cardSection);
|
||
|
nodeFinished();
|
||
|
}
|
||
|
|
||
|
function figureToCodeCard(node, builder, {addSection, nodeFinished}) {
|
||
|
if (node.nodeType !== 1 || node.tagName !== 'FIGURE') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let pre = node.querySelector('pre');
|
||
|
|
||
|
// If this figure doesn't have a pre tag in it
|
||
|
if (!pre) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let code = pre.querySelector('code');
|
||
|
let figcaption = node.querySelector('figcaption');
|
||
|
|
||
|
// if there's no caption the preCodeToCard plugin will pick it up instead
|
||
|
if (!code || !figcaption) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let payload = {
|
||
|
code: code.textContent
|
||
|
};
|
||
|
|
||
|
_readFigCaptionFromNode(node, payload);
|
||
|
|
||
|
let preClass = pre.getAttribute('class') || '';
|
||
|
let codeClass = code.getAttribute('class') || '';
|
||
|
let langRegex = /lang(?:uage)?-(.*?)(?:\s|$)/i;
|
||
|
let languageMatches = preClass.match(langRegex) || codeClass.match(langRegex);
|
||
|
if (languageMatches) {
|
||
|
payload.language = languageMatches[1].toLowerCase();
|
||
|
}
|
||
|
|
||
|
let cardSection = builder.createCardSection('code', payload);
|
||
|
addSection(cardSection);
|
||
|
nodeFinished();
|
||
|
}
|
||
|
|
||
|
function preCodeToCard(node, builder, {addSection, nodeFinished}) {
|
||
|
if (node.nodeType !== 1 || node.tagName !== 'PRE') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let [codeElement] = node.children;
|
||
|
|
||
|
if (codeElement && codeElement.tagName === 'CODE') {
|
||
|
let payload = {code: codeElement.textContent};
|
||
|
|
||
|
let preClass = node.getAttribute('class') || '';
|
||
|
let codeClass = codeElement.getAttribute('class') || '';
|
||
|
let langRegex = /lang(?:uage)?-(.*?)(?:\s|$)/i;
|
||
|
let languageMatches = preClass.match(langRegex) || codeClass.match(langRegex);
|
||
|
if (languageMatches) {
|
||
|
payload.language = languageMatches[1].toLowerCase();
|
||
|
}
|
||
|
|
||
|
let cardSection = builder.createCardSection('code', payload);
|
||
|
addSection(cardSection);
|
||
|
nodeFinished();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function figureScriptToHtmlCard(node, builder, {addSection, nodeFinished}) {
|
||
|
if (node.nodeType !== 1 || node.tagName !== 'FIGURE') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let script = node.querySelector('script');
|
||
|
|
||
|
if (!script || !script.src.match(/^https:\/\/gist\.github\.com/)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let payload = {html: script.outerHTML};
|
||
|
let cardSection = builder.createCardSection('html', payload);
|
||
|
addSection(cardSection);
|
||
|
nodeFinished();
|
||
|
}
|
||
|
|
||
|
// Nested paragraphs in blockquote are currently treated as separate blockquotes,
|
||
|
// see [here](https://github.com/bustle/mobiledoc-kit/issues/715). When running migrations,
|
||
|
// this is not the desired behaviour and will cause the content to lose the previous semantic.
|
||
|
function blockquoteWithChildren(node) {
|
||
|
if (node.nodeType !== 1 || node.tagName !== 'BLOCKQUOTE' || node.children.length < 1) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const html = [];
|
||
|
const children = Array.from(node.children);
|
||
|
|
||
|
children.forEach((child) => {
|
||
|
let nextSibling = child.nextSibling;
|
||
|
let previousSibling = child.previousSibling;
|
||
|
|
||
|
// Only add a soft-break for two sequential paragraphs.
|
||
|
// Use the innerHTML only in that case, so Mobiledoc's default behaviour
|
||
|
// of creating separate blockquotes doesn't apply.
|
||
|
if (child.tagName === 'P' && (nextSibling && nextSibling.tagName === 'P')) {
|
||
|
html.push(`${child.innerHTML}<br><br>`);
|
||
|
} else if (child.tagName === 'P' && (previousSibling && previousSibling.tagName === 'P')) {
|
||
|
html.push(child.innerHTML);
|
||
|
} else {
|
||
|
html.push(child.outerHTML);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
node.innerHTML = html.join('').trim();
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// we store alt-style blockquotes as `aside` sections as a workaround
|
||
|
// for mobiledoc not allowing arbitrary attributes on markup sections
|
||
|
function altBlockquoteToAside(node) {
|
||
|
if (node.nodeType !== 1 || node.tagName !== 'BLOCKQUOTE') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (!node.classList.contains('kg-blockquote-alt')) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const replacementDoc = options.createDocument(`<aside>${node.innerHTML}</aside>`);
|
||
|
const aside = replacementDoc.querySelector('aside');
|
||
|
|
||
|
// bit of an ugly hack because
|
||
|
// 1. node.tagName is readonly so we can't directly change it's type
|
||
|
// 2. the node list of the current tree branch is not re-evaluated so removing
|
||
|
// this node, replacing it, or adding a new _sibling_ will not be picked up
|
||
|
//
|
||
|
// relies on mobiledoc-kit's handling of nested elements picking the
|
||
|
// inner-most understandable section element when creating sections
|
||
|
node.textContent = '';
|
||
|
node.appendChild(aside);
|
||
|
|
||
|
// let the default parser handle the nested aside node, keeping any formatting
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
function tableToHtmlCard(node, builder, {addSection, nodeFinished}) {
|
||
|
if (node.nodeType !== 1 || node.tagName !== 'TABLE') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (node.parentNode.tagName === 'TABLE') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let payload = {html: node.outerHTML};
|
||
|
let cardSection = builder.createCardSection('html', payload);
|
||
|
addSection(cardSection);
|
||
|
nodeFinished();
|
||
|
}
|
||
|
|
||
|
return [
|
||
|
embedCard.fromNFTEmbed(),
|
||
|
embedCard.fromMixtape(options),
|
||
|
htmlCard.fromKoenigCard(options),
|
||
|
buttonCard.fromKoenigCard(options),
|
||
|
buttonCard.fromWordpressButton(options),
|
||
|
buttonCard.fromSubstackButton(options),
|
||
|
kgCalloutCardToCard,
|
||
|
kgToggleCardToCard,
|
||
|
productCard.fromKoenigCard(options),
|
||
|
audioCard.fromKoenigCard(options),
|
||
|
videoCard.fromKoenigCard(options),
|
||
|
fileCard.fromKoenigCard(options),
|
||
|
headerCard.fromKoenigCard(options),
|
||
|
blockquoteWithChildren,
|
||
|
softReturn.fromBr(options),
|
||
|
removeLeadingNewline,
|
||
|
galleryCard.fromKoenigCard(options),
|
||
|
embedCard.fromFigureBlockquote(options), // I think these can contain images
|
||
|
galleryCard.fromGrafGallery(options),
|
||
|
galleryCard.fromSqsGallery(options),
|
||
|
imageCard.fromFigure(options),
|
||
|
imageCard.fromImg(options),
|
||
|
hrToCard,
|
||
|
figureToCodeCard,
|
||
|
preCodeToCard,
|
||
|
embedCard.fromFigureIframe(options),
|
||
|
embedCard.fromIframe(options), // Process iFrames without figures after ones with
|
||
|
figureScriptToHtmlCard,
|
||
|
altBlockquoteToAside,
|
||
|
tableToHtmlCard
|
||
|
];
|
||
|
}
|