apostrophe
Version:
The Apostrophe Content Management System.
1,353 lines (1,281 loc) • 43.4 kB
JavaScript
// Implements rich text editor widgets. Unlike most widget types, the rich text
// editor does not use a modal; instead you edit in context on the page.
const sanitizeHtml = require('sanitize-html');
const cheerio = require('cheerio');
const { createWriteStream, unlinkSync } = require('fs');
const { Readable, pipeline } = require('stream');
const apiRoutes = require('./lib/apiRoutes');
const util = require('util');
module.exports = {
extend: '@apostrophecms/widget-type',
cascades: [ 'linkFields' ],
linkFields(self, options) {
const linkWithType = (Array.isArray(options.linkWithType)
? options.linkWithType
: [ options.linkWithType ]);
// Labels are not available at the time the schema is built,
// they are added on modulesRegistered.
const linkWithTypeChoices = linkWithType
.map(type => ({
label: type,
value: type
}))
.concat([
{
label: 'apostrophe:url',
value: '_url'
}
]);
const linkWithTypeFields = linkWithType.reduce((fields, type) => {
const name = `_${type}`;
fields[name] = {
type: 'relationship',
label: type,
withType: type,
required: true,
max: 1,
browse: true,
if: {
linkTo: type
}
};
return fields;
}, {});
const orTypes = linkWithType.map(type => ({
linkTo: type
}));
linkWithTypeFields.updateTitle = {
label: 'apostrophe:updateTitle',
type: 'boolean',
// Optional.
// Can be Link and/or Image
extensions: [ 'Link' ],
def: true,
if: {
$or: orTypes
}
};
return {
add: {
linkTo: {
label: 'apostrophe:linkTo',
type: 'select',
choices: linkWithTypeChoices,
required: true,
def: linkWithTypeChoices[0].value
},
...linkWithTypeFields,
href: {
label: 'apostrophe:url',
help: 'apostrophe:linkHrefHelp',
type: 'string',
required: true,
if: {
linkTo: '_url'
}
},
hrefTitle: {
label: 'apostrophe:linkTitle',
help: 'apostrophe:linkTitleUrlHelp',
type: 'string',
if: {
linkTo: '_url'
}
},
title: {
label: 'apostrophe:linkTitle',
help: 'apostrophe:linkTitleRelHelp',
type: 'string',
if: {
$or: orTypes
}
},
target: {
label: 'apostrophe:linkTarget',
type: 'checkboxes',
htmlAttribute: 'target',
choices: [
{
label: 'apostrophe:openLinkInNewTab',
value: '_blank'
}
],
if: {
$or: linkWithType.map(type => ({
linkTo: type
}))
.concat([ {
linkTo: '_url'
} ])
}
}
}
};
},
options: {
icon: 'format-text-icon',
label: 'apostrophe:richText',
contextual: true,
contextualStyles: true,
placeholder: true,
placeholderText: 'apostrophe:richTextPlaceholder',
placeholderTextWithInsertMenu: 'apostrophe:richTextPlaceholderWithInsertMenu',
defaultData: { content: '' },
className: false,
linkWithType: [ '@apostrophecms/any-page-type' ],
tableOptions: {
resizable: true,
handleWidth: 10,
cellMinWidth: 100,
lastColumnResizable: false,
class: 'apos-rich-text-table'
},
// For permalinks and images. For efficiency we make
// one query
project: {
title: 1,
aposDocId: 1,
_url: 1,
attachment: 1,
alt: 1,
credit: 1,
creditUrl: 1
},
minimumDefaultOptions: {
toolbar: [
'styles',
'|',
'bold',
'italic',
'strike',
'underline',
'subscript',
'superscript',
'blockquote',
'|',
'alignLeft',
'alignCenter',
'alignRight',
'image',
'horizontalRule',
'link',
'anchor',
'bulletList',
'orderedList',
'color'
],
styles: [
// you may also use a `class` property with these
{
tag: 'p',
label: 'apostrophe:richTextParagraph'
},
{
tag: 'h1',
label: 'apostrophe:richTextH1'
},
{
tag: 'h2',
label: 'apostrophe:richTextH2'
},
{
tag: 'h3',
label: 'apostrophe:richTextH3'
},
{
tag: 'h4',
label: 'apostrophe:richTextH4'
},
{
tag: 'h5',
label: 'apostrophe:richTextH5'
},
{
tag: 'h6',
label: 'apostrophe:richTextH6'
}
],
insert: [
'image',
'table',
'importTable',
'horizontalRule'
]
},
defaultOptions: {},
components: {
widgetEditor: 'AposRichTextWidgetEditor'
},
editorTools: {
nodes: {
component: 'AposTiptapStyles',
label: 'apostrophe:richTextNodeStyles',
icon: 'format-text-icon'
},
marks: {
component: 'AposTiptapMarks',
label: 'apostrophe:richTextMarkStyles',
icon: 'palette-swatch-icon'
},
'|': { component: 'AposTiptapDivider' },
bold: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextBold',
icon: 'format-bold-icon',
command: 'toggleBold',
iconSize: 18
},
italic: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextItalic',
icon: 'format-italic-icon',
command: 'toggleItalic'
},
underline: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextUnderline',
icon: 'format-underline-icon',
command: 'toggleUnderline'
},
strike: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextStrikethrough',
icon: 'format-strikethrough-variant-icon',
command: 'toggleStrike',
iconSize: 14
},
superscript: {
component: 'AposTiptapButton',
label: 'apostrophe:superscript',
icon: 'format-superscript-icon',
command: 'toggleSuperscript'
},
subscript: {
component: 'AposTiptapButton',
label: 'apostrophe:subscript',
icon: 'format-subscript-icon',
command: 'toggleSubscript'
},
horizontalRule: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextHorizontalRule',
icon: 'minus-icon',
command: 'setHorizontalRule'
},
link: {
component: 'AposTiptapLink',
label: 'apostrophe:richTextLink',
icon: 'link-icon',
iconSize: 18
},
anchor: {
component: 'AposTiptapAnchor',
label: 'apostrophe:richTextAnchor',
icon: 'anchor-icon'
},
bulletList: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextBulletedList',
icon: 'format-list-bulleted-icon',
command: 'toggleBulletList'
},
orderedList: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextOrderedList',
icon: 'format-list-numbered-icon',
command: 'toggleOrderedList'
},
blockquote: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextBlockquote',
icon: 'format-quote-close-icon',
command: 'toggleBlockquote',
iconSize: 20
},
codeBlock: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextCodeBlock',
icon: 'code-tags-icon',
command: 'toggleCode'
},
undo: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextUndo',
icon: 'undo-icon'
},
redo: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextRedo',
icon: 'redo-icon'
},
alignLeft: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextAlignLeft',
icon: 'format-align-left-icon',
command: 'setTextAlign',
commandParameters: 'left',
isActive: { textAlign: 'left' }
},
alignCenter: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextAlignCenter',
icon: 'format-align-center-icon',
command: 'setTextAlign',
commandParameters: 'center',
isActive: { textAlign: 'center' }
},
alignRight: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextAlignRight',
icon: 'format-align-right-icon',
command: 'setTextAlign',
commandParameters: 'right',
isActive: { textAlign: 'right' }
},
alignJustify: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextAlignJustify',
icon: 'format-align-justify-icon',
command: 'setTextAlign',
commandParameters: 'justify',
isActive: { textAlign: 'justify' }
},
highlight: {
component: 'AposTiptapButton',
label: 'apostrophe:richTextHighlight',
icon: 'format-color-highlight-icon',
command: 'toggleHighlight'
},
image: {
component: 'AposTiptapImage',
label: 'apostrophe:image',
icon: 'image-icon'
},
color: {
component: 'AposTiptapColor',
label: 'apostrophe:richTextColor',
command: 'setColor'
},
importTable: {
component: 'AposTiptapImportTable',
icon: 'cloud-upload-icon',
label: 'apostrophe:richTextUploadCsvTable'
}
},
editorInsertMenu: {
table: {
icon: 'table-icon',
label: 'apostrophe:table',
description: 'apostrophe:tableDescription',
action: 'insertTable'
},
image: {
icon: 'image-icon',
label: 'apostrophe:image',
description: 'apostrophe:imageDescription',
component: 'AposImageControlDialog'
},
horizontalRule: {
icon: 'minus-icon',
label: 'apostrophe:richTextHorizontalRule',
description: 'apostrophe:richTextHorizontalRuleDescription',
action: 'setHorizontalRule'
},
importTable: {
icon: 'cloud-upload-icon',
label: 'apostrophe:tableImport',
description: 'apostrophe:richTextUploadCsvTable',
component: 'AposTiptapImportTable',
noPopover: true
}
},
// Additional properties used in executing tiptap commands
// Will be mixed in automatically for developers
tiptapTextCommands: {
setNode: [ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'div' ],
toggleMark: [
'b', 'strong', 'code', 'mark', 'em', 'i',
'a', 's', 'del', 'strike', 'u', 'anchor',
'superscript', 'subscript'
],
toggleClassOrToggleMark: [ 'span' ],
wrapIn: [ 'blockquote' ]
},
tiptapTypes: {
heading: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ],
paragraph: [ 'p' ],
code: [ 'code' ],
bold: [ 'strong', 'b' ],
strike: [ 's', 'del', 'strike' ],
italic: [ 'i', 'em' ],
highlight: [ 'mark' ],
link: [ 'a' ],
anchor: [ 'span' ],
underline: [ 'u' ],
codeBlock: [ 'pre' ],
blockquote: [ 'blockquote' ],
superscript: [ 'sup' ],
subscript: [ 'sub' ],
textStyle: [ 'span' ],
// Generic div type, usually used with classes,
// and for A2 content migration. Intentionally not
// given a nicer-sounding name
div: [ 'div' ]
}
},
beforeSuperClass(self) {
self.options.defaultOptions = {
...self.options.minimumDefaultOptions,
...self.options.defaultOptions
};
},
icons: {
'format-text-icon': 'FormatText',
'format-color-highlight-icon': 'FormatColorHighlight',
'table-icon': 'Table',
'palette-swatch-icon': 'PaletteSwatch',
'table-row-plus-after-icon': 'TableRowPlusAfter',
'table-column-plus-after-icon': 'TableColumnPlusAfter',
'table-column-remove-icon': 'TableColumnRemove',
'table-row-remove-icon': 'TableRowRemove',
'table-plus-icon': 'TablePlus',
'table-minus-icon': 'TableMinus',
'table-column-icon': 'TableColumn',
'table-row-icon': 'TableRow',
'table-split-cell-icon': 'TableSplitCell',
'table-merge-cells-icon': 'TableMergeCells'
},
handlers(self) {
return {
'apostrophe:modulesRegistered': {
validateAndFixLinkWithTypes() {
self.validateAndFixLinkWithTypes();
},
composeLinkSchema() {
self.composeLinkSchema();
}
}
};
},
apiRoutes,
methods(self) {
return {
// Return just the rich text of the widget, which may be undefined or
// null if it has not yet been edited
getRichText(widget) {
return widget.content;
},
// Handle relationships to permalinks and inline images
async load(req, widgets) {
try {
return await self.loadInlineRelationships(req, widgets, [ 'permalinkIds', 'imageIds' ]);
} catch (e) {
console.error(e);
throw e;
}
},
// Load the permalink and image relationships for the given rich text
// widgets, using a single efficient query. `names` is an array of
// properties that contain arrays of doc IDs, such as `permalinkIds`.
// An implementation detail of `load` for this widget. After this method
// each widget has a `_relatedDocs` property. Note this is a mixed
// collection of permalink docs and inline image docs
async loadInlineRelationships(req, widgets, names) {
const widgetsByDocId = new Map();
let ids = [];
for (const widget of widgets) {
for (const name of names) {
if (!widget[name]) {
continue;
}
for (const id of widget[name]) {
const docWidgets = widgetsByDocId.get(id) || [];
docWidgets.push(widget);
widgetsByDocId.set(id, docWidgets);
ids.push(id);
}
}
}
ids = [ ...new Set(ids) ];
if (!ids.length) {
return;
}
const docs = await self.apos.doc.find(req, {
aposDocId: {
$in: ids
}
}).project(self.options.project).toArray();
for (const doc of docs) {
const widgets = widgetsByDocId.get(doc.aposDocId) || [];
for (const widget of widgets) {
widget._relatedDocs = widget._relatedDocs || [];
widget._relatedDocs.push(doc);
}
}
},
// Convert area rich text options into a valid sanitize-html
// configuration, so that h4 can be legal in one area and illegal in
// another.
optionsToSanitizeHtml(options) {
return {
...sanitizeHtml.defaults,
allowedTags: self.toolbarToAllowedTags(options),
allowedAttributes: self.toolbarToAllowedAttributes(options),
allowedClasses: self.toolbarToAllowedClasses(options),
allowedStyles: self.toolbarToAllowedStyles(options)
};
},
toolbarToAllowedTags(options) {
const allowedTags = {
br: true,
p: true
};
const simple = {
bold: [
'b',
'strong'
],
italic: [
'i',
'em'
],
strike: [ 's' ],
link: [ 'a' ],
horizontalRule: [ 'hr' ],
bulletList: [
'ul',
'li'
],
orderedList: [
'ol',
'li'
],
blockquote: [ 'blockquote' ],
codeBlock: [
'pre',
'code'
],
underline: [ 'u' ],
anchor: [ 'span' ],
superscript: [ 'sup' ],
subscript: [ 'sub' ],
table: [ 'table', 'tr', 'td', 'th', 'colgroup', 'col', 'div' ],
image: [ 'figure', 'img', 'figcaption' ],
div: [ 'div' ],
color: [ 'span' ]
};
for (const item of self.combinedItems(options)) {
if (simple[item]) {
for (const tag of simple[item]) {
allowedTags[tag] = true;
}
} else if (item === 'styles') {
for (const style of options.styles || []) {
const tag = style.tag;
allowedTags[tag] = true;
}
}
}
return Object.keys(allowedTags);
},
toolbarToAllowedAttributes(options) {
const allowedAttributes = {};
const simple = {
link: {
tag: 'a',
attributes: [
'href',
'id',
'name',
'title',
'rel',
...self.linkSchema
.filter(field => field.htmlAttribute)
.map(field => field.htmlAttribute)
]
},
alignLeft: {
tag: '*',
attributes: [ 'style' ]
},
alignCenter: {
tag: '*',
attributes: [ 'style' ]
},
alignRight: {
tag: '*',
attributes: [ 'style' ]
},
alignJustify: {
tag: '*',
attributes: [ 'style' ]
},
anchor: {
tag: 'span',
attributes: [ 'id' ]
},
table: [
{
tag: 'div',
attributes: [ 'class' ]
},
{
tag: 'table',
attributes: [ 'style', 'class' ]
},
{
tag: 'col',
attributes: [ 'style' ]
},
{
tag: 'td',
attributes: [ 'colspan', 'rowspan', 'colwidth' ]
},
{
tag: 'th',
attributes: [ 'colspan', 'rowspan', 'colwidth' ]
}
],
colgroup: [
{
tag: 'col',
attributes: [ 'style' ]
}
],
image: [
{
tag: 'figure',
attributes: [ 'class' ]
},
{
tag: 'a',
attributes: [
'href',
'name',
'title',
'rel',
...self.linkSchema
.filter(field => field.htmlAttribute)
.map(field => field.htmlAttribute) ]
},
{
tag: 'img',
attributes: [ 'src', 'alt' ]
}
],
color: {
tag: '*',
attributes: [ 'style' ]
}
};
for (const item of self.combinedItems(options)) {
if (simple[item]) {
const entries = Array.isArray(simple[item]) ? simple[item] : [ simple[item] ];
for (const entry of entries) {
for (const attribute of entry.attributes) {
allowedAttributes[entry.tag] = allowedAttributes[entry.tag] || [];
allowedAttributes[entry.tag].push(attribute);
allowedAttributes[entry.tag] = [
...new Set(allowedAttributes[entry.tag])
];
}
}
}
}
return allowedAttributes;
},
toolbarToAllowedStyles(options) {
const allowedStyles = {};
const simple = {
alignLeft: {
selector: '*',
properties: {
'text-align': [ /^left$/ ]
}
},
alignCenter: {
selector: '*',
properties: {
'text-align': [ /^center$/ ]
}
},
alignRight: {
selector: '*',
properties: {
'text-align': [ /^right$/ ]
}
},
alignJustify: {
selector: '*',
properties: {
'text-align': [ /^justify$/ ]
}
},
color: {
selector: '*',
properties: {
color: [
// Hexadecimal colors (3 or 6 digits, optionally with alpha)
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8}|[0-9a-fA-F]{4})$/i,
// RGB colors
/^rgb\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/,
// RGBA colors
/^rgba\(\s*(?:\d{1,3}\s*,\s*){3}(?:0?\.\d+|1(?:\.0)?)\s*\)$/,
// HSL colors
/^hsl\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*\)$/,
// HSLA colors
/^hsla\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*,\s*(?:0?\.\d+|1(?:\.0)?)\s*\)$/,
// CSS Variable value
/^var\(--[a-zA-Z0-9-]+\)$/
]
}
},
table: {
selector: '*',
properties: {
'min-width': [ /[0-9]{1,4}(px|em|%)/i ],
'max-width': [ /[0-9]{1,4}(px|em|%)/i ],
width: [ /[0-9]{1,4}(px|em|%)/i ]
}
}
};
for (const item of self.combinedItems(options)) {
if (simple[item]) {
if (!allowedStyles[simple[item].selector]) {
allowedStyles[simple[item].selector] = {};
}
for (const property in simple[item].properties) {
if (!allowedStyles[simple[item].selector][property]) {
allowedStyles[simple[item].selector][property] = [];
}
allowedStyles[simple[item].selector][property]
.push(...simple[item].properties[property]);
}
}
}
return allowedStyles;
},
toolbarToAllowedClasses(options) {
const allowedClasses = {};
if (self.combinedItems(options).includes('styles')) {
for (const style of options.styles || []) {
const tag = style.tag;
const classes = self.getStyleClasses(style);
// Add classes to THIS tag's allowList
if (tag) {
allowedClasses[tag] = allowedClasses[tag] || {};
for (const c of classes) {
allowedClasses[tag][c] = true;
}
}
}
}
if (options.toolbar?.includes('table') || options.insert?.includes('table')) {
allowedClasses.div = {
...(allowedClasses.div || {})
};
// If `resizable`, allow the prosemirror wrapper through
if (self.options.tableOptions?.resizable) {
allowedClasses.div.tableWrapper = true;
}
allowedClasses.table = {
...(allowedClasses.table || {})
};
// If custom class applied to table
if (self.options.tableOptions?.class) {
allowedClasses.table[self.options.tableOptions.class] = true;
}
}
for (const tag of Object.keys(allowedClasses)) {
allowedClasses[tag] = Object.keys(allowedClasses[tag]);
}
return allowedClasses;
},
// Returns a combined array of toolbar and insert menu items from the
// given set of rich text widget options
combinedItems(options) {
return [ ...(options.toolbar || []), ...(options.insert || []) ];
},
getStyleClasses(heading) {
if (!heading.class) {
return [];
}
return heading.class.split(/\s+/);
},
addSearchTexts(item, texts) {
texts.push({
weight: 10,
text: self.apos.util.htmlToPlaintext(item.content),
silent: false
});
},
isEmpty(widget) {
const content = (widget.content || '').trim();
const text = self.apos.util.htmlToPlaintext(content).trim();
return text.length === 0 &&
content.includes('<table') === false &&
content.includes('<figure') === false;
},
sanitizeHtml(html, options) {
html = sanitizeHtml(html, options);
html = self.sanitizeAnchors(html);
return html;
},
sanitizeAnchors(html) {
const $ = cheerio.load(html);
const seen = new Set();
$('[data-anchor]').each(function() {
const $el = $(this);
const anchor = $el.attr('data-anchor');
if (!self.validateAnchor(anchor)) {
return;
}
// tiptap will apply data-anchor to every tag involved in the
// selection at any depth. For ids and anchors this doesn't really
// make sense. Save the id to the first, rootmost tag involved
if (!seen.has(anchor)) {
$el.attr('id', anchor);
seen.add(anchor);
}
});
const result = $('body').html();
return result;
},
validateAnchor(anchor) {
if ((typeof anchor) !== 'string') {
return false;
}
if (!anchor.length) {
return false;
}
// Don't let them break the editor
if (anchor.startsWith('apos-')) {
return false;
}
return true;
},
// Quickly replaces rich text permalink placeholder URLs with
// actual, SEO-friendly URLs based on `widget._relatedDocs`.
linkPermalinks(widget, content) {
// "Why no regexps?" We need to do this as quickly as we can.
// indexOf and lastIndexOf are much faster.
let i;
for (const doc of (widget._relatedDocs || [])) {
let offset = 0;
while (true) {
i = content.indexOf('apostrophe-permalink-' + doc.aposDocId, offset);
if (i === -1) {
break;
}
offset = i + ('apostrophe-permalink-' + doc.aposDocId).length;
let updateTitle = content.indexOf('?updateTitle=1', i);
if (updateTitle === i + ('apostrophe-permalink-' + doc.aposDocId).length) {
updateTitle = true;
} else {
updateTitle = false;
}
// If you can edit the widget, you don't want the link replaced,
// as that would lose the permalink if you edit the widget
// (but you may still want the title updated)
const left = content.lastIndexOf('<', i);
const href = content.indexOf(' href="', left);
const close = content.indexOf('"', href + 7);
if ((left !== -1) && (href !== -1) && (close !== -1)) {
content = content.substring(0, href + 6) +
doc._url +
content.substring(close + 1);
} else {
// So we don't get stuck in an infinite loop
break;
}
if (!updateTitle) {
continue;
}
const right = content.indexOf('>', left);
const nextLeft = content.indexOf('<', right);
if ((right !== -1) && (nextLeft !== -1)) {
content = content.substring(0, right + 1) +
self.apos.util.escapeHtml(doc.title) +
content.substring(nextLeft);
}
}
}
return content;
},
// Quickly replaces inline image placeholder URLs with
// actual, SEO-friendly URLs based on `widget._relatedDocs`.
linkImages(widget, content) {
// "Why no regexps?" We need to do this as quickly as we can.
// indexOf and lastIndexOf are much faster.
let i;
for (const doc of (widget._relatedDocs || [])) {
let offset = 0;
while (true) {
const target = `${self.apos.modules['@apostrophecms/image'].action}/${doc.aposDocId}/src`;
i = content.indexOf(target, offset);
if (i === -1) {
break;
}
offset = i + target.length;
// If you can edit the widget, you don't want the link replaced,
// as that would lose the image if you edit the widget
const left = content.lastIndexOf('<', i);
const src = content.indexOf(' src="', left);
const close = content.indexOf('"', src + 6);
if (left === -1 || src === -1 || close === -1) {
// So we don't get stuck in an infinite loop
break;
}
content = content.substring(0, src + 5) + '"' + doc.attachment._urls[self.apos.modules['@apostrophecms/image'].getLargestSize()] + '"' + content.substring(close + 1);
const tagEnd = content.indexOf('>', left);
if (tagEnd === -1) {
continue;
}
let imgTag = content.substring(left, tagEnd + 1);
const altAttr = ' alt="';
const altIndex = imgTag.indexOf(altAttr);
if (altIndex === -1) {
continue;
}
const altValueStart = altIndex + altAttr.length;
const altValueEnd = imgTag.indexOf('"', altValueStart);
// Replace the existing alt value in the img tag
imgTag = imgTag.substring(0, altValueStart) + self.apos.util.escapeHtml(doc.alt || '') + imgTag.substring(altValueEnd);
// Replace the img tag in content
content = content.substring(0, left) + imgTag + content.substring(tagEnd + 1);
}
}
return content;
},
// Validate the types provided for links, update labels derived from
// corresponding modules, as they are not available at the time
// the schema is generated.
validateAndFixLinkWithTypes() {
const linkWithType = (Array.isArray(self.options.linkWithType)
? self.options.linkWithType
: [ self.options.linkWithType ]);
for (const type of linkWithType) {
if (!self.apos.modules[type]) {
throw new Error(
`The linkWithType option of rich text widget "${type}" must be a valid module type`
);
}
self.linkFields[`_${type}`].label = getLabel(type);
const choice = self.linkFields.linkTo.choices
.find(choice => choice.value === type);
choice.label = getLabel(type);
}
function getLabel(type) {
if ([ '@apostrophecms/any-page-type', '@apostrophecms/page' ].includes(type)) {
return 'apostrophe:page';
}
return self.apos.modules[type].options?.label ?? type;
}
},
// Compose and register the link schema.
composeLinkSchema() {
self.linkSchema = self.apos.schema.compose({
addFields: self.apos.schema.fieldsToArray(`Links ${self.__meta.name}`, self.linkFields),
arrangeFields: self.apos.schema.groupsToArray(self.linkFieldsGroups)
}, self);
self.apos.schema.validate(self.linkSchema, {
type: 'link',
subtype: self.__meta.name
});
// Don't allow htmlAttribute `href`, it's a special case.
const hrefField = self.linkSchema.find(field => field.htmlAttribute === 'href');
if (hrefField) {
throw new Error(`Field "${hrefField.name}" validation error: "htmlAttribute: href" is not allowed.`);
}
}
};
},
extendMethods(self) {
return {
async sanitize(_super, req, input, options) {
try {
const rteOptions = {
...self.options.defaultOptions,
...options
};
const output = await _super(req, input, rteOptions);
const finalOptions = self.optionsToSanitizeHtml(rteOptions);
if (input.import?.html) {
if ((typeof input.import.html) !== 'string') {
throw self.apos.error('invalid', 'import.html must be a string');
}
if (input.import.baseUrl && ((typeof input.import.html) !== 'string')) {
throw self.apos.error('invalid', 'If present, import.baseUrl must be a string');
}
const $ = cheerio.load(input.import.html);
const $images = $('img');
// Build an array of cheerio objects because
// we need to iterate while doing async work,
// which .each() can't do
const $$images = [];
$images.each((i, el) => {
const $image = $(el);
$$images.push($image);
});
for (const $image of $$images) {
const src = $image.attr('src');
const alt = $image.attr('alt') && self.apos.util.escapeHtml($image.attr('alt'));
const url = new URL(src, input.import.baseUrl || self.apos.baseUrl);
const res = await fetch(url);
if (res.status >= 400) {
self.apos.util.warn(`Error ${res.status} while importing ${src}, ignoring image`);
continue;
}
const id = self.apos.util.generateId();
const temp = self.apos.attachment.uploadfs.getTempPath() + `/${id}`;
const matches = src.match(/\/([^/]+\.\w+)$/);
if (!matches) {
self.apos.util.warn('img URL has no extension, skipping:', src);
continue;
}
const name = matches[1];
try {
await util.promisify(pipeline)(
Readable.fromWeb(res.body),
createWriteStream(temp)
);
const attachment = await self.apos.attachment.insert(req, {
name,
path: temp
});
const image = await self.apos.image.insert(req, {
title: name,
alt,
attachment,
tagsIds: input.import.imageTags || []
});
const newSrc = `${self.apos.image.action}/${image.aposDocId}/src`;
$image.replaceWith(
`<figure>
<img src="${newSrc}" ${alt && `alt="${alt}"`} />
<figcaption></figcaption>
</figure>
`
);
} finally {
try {
unlinkSync(temp);
} catch (e) {
// It's OK if we never created it
}
}
}
input.content = $.html();
}
output.content = self.sanitizeHtml(input.content, finalOptions);
const permalinkAnchors = output.content.match(/"#apostrophe-permalink-[^"?]*?\?/g);
output.permalinkIds = (permalinkAnchors && permalinkAnchors.map(anchor => {
const matches = anchor.match(/apostrophe-permalink-(.*)\?/);
return matches[1];
})) || [];
const quotedAction = self.apos.util.regExpQuote(self.apos.modules['@apostrophecms/image'].action);
const imageAnchors = output.content.match(new RegExp(`${quotedAction}/([^/]+)/src`, 'g'));
output.imageIds = (imageAnchors && imageAnchors.map(anchor => {
const matches = anchor.match(new RegExp(`${quotedAction}/([^/]+)/src`));
return matches[1];
})) || [];
return output;
} catch (e) {
// Because the trace for template errors is not very
// useful, for now we log any error here up front
// until we improve that or this has been stable for a while
console.error(e);
throw e;
}
},
async output(_super, req, widget, options, _with) {
let content = widget.content || '';
content = self.linkPermalinks(widget, content);
content = self.linkImages(widget, content);
// We never modify the original widget.content because we do not want
// it to lose its permalinks in the database
const _widget = {
...widget,
content
};
return _super(req, _widget, options, _with);
},
annotateWidgetForExternalFront(_super, widget, { scene } = {}) {
if (scene !== 'apos') {
let content = widget.content || '';
content = self.linkPermalinks(widget, content);
content = self.linkImages(widget, content);
// It should be safe to modify the widget data here because
// the external front-end data is read-only. The Admin UI
// and actual data is not affected.
widget.content = content;
}
return _super(widget, { scene });
},
// Add on the core default options to use, if needed.
getBrowserData(_super, req) {
const initialData = _super(req);
const finalData = {
...initialData,
tools: self.options.editorTools,
insertMenu: self.options.editorInsertMenu,
defaultOptions: self.options.defaultOptions,
tiptapTextCommands: self.options.tiptapTextCommands,
tiptapTypes: self.options.tiptapTypes,
placeholderText: self.options.placeholder && self.options.placeholderText,
// Not optional in presence of an insert menu, it's not acceptable UX
// without it
placeholderTextWithInsertMenu: self.options.placeholderTextWithInsertMenu,
linkWithType: Array.isArray(self.options.linkWithType)
? self.options.linkWithType
: [ self.options.linkWithType ],
linkSchema: self.linkSchema,
imageStyles: self.options.imageStyles,
color: self.options.color,
tableOptions: self.options.tableOptions
};
return finalData;
}
};
},
tasks(self) {
const confirm = async (isConfirmed) => {
if (isConfirmed) {
return true;
}
console.log('This task will perform an update on all existing rich-text widget. You should manually backup your database before running this command in case it becomes necessary to revert the changes. You can add --confirm to the command to skip this message and run the command');
return false;
};
return {
'remove-empty-paragraph': {
usage: 'Usage: node app @apostrophecms/rich-text-widget:remove-empty-paragraph --confirm\n\nRemove empty paragraph. If a paragraph contains no visible text or only blank characters, it will be removed.\n',
task: async (argv) => {
const iterator = async (doc, widget, dotPath) => {
if (widget.type !== self.name) {
return;
}
const updates = {};
if (widget.content.includes('<p>')) {
const dom = cheerio.load(widget.content);
const paragraph = dom('body').find('p');
paragraph.each((index, element) => {
const isEmpty = /^(\s| )*$/.test(dom(element).text());
isEmpty && dom(element).remove();
if (isEmpty) {
updates[dotPath] = {
...widget,
content: dom('body').html()
};
}
});
}
if (Object.keys(updates).length) {
await self.apos.doc.db.updateOne(
{ _id: doc._id },
{ $set: updates }
);
self.apos.util.log(`Document ${doc._id} rich-texts have been updated`);
}
};
const isConfirmed = await confirm(argv.confirm);
return isConfirmed && self.apos.migration.eachWidget({}, iterator);
}
},
'lint-fix-figure': {
usage: 'Usage: node app @apostrophecms/rich-text-widget:lint-fix-figure --confirm\n\nFigure tags is allowed inside paragraph. This task will look for figure tag next to empty paragraph and wrap the text node around inside paragraph.\n',
task: async (argv) => {
const blockNodes = [
'address',
'article',
'aside',
'blockquote',
'canvas',
'dd',
'div',
'dl',
'dt',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'li',
'main',
'nav',
'noscript',
'ol',
'p',
'pre',
'section',
'table',
'tfoot',
'ul',
'video'
];
const append = ({
dom,
wrapper,
element
}) => {
return wrapper
? wrapper.append(element) && wrapper
: dom(element).wrap('<p></p>').parent();
};
const iterator = async (doc, widget, dotPath) => {
if (widget.type !== self.name) {
return;
}
const updates = {};
if (widget.content.includes('<figure')) {
const dom = cheerio.load(widget.content);
// reference: https://stackoverflow.com/questions/28855070/css-select-element-without-text-inside
const figure = dom('body').find('p:not(:has(:not(:empty)))+figure,figure+p:not(:has(:not(:empty)))');
const parent = figure.parent().contents();
let wrapper = null;
parent.each((index, element) => {
const isFigure = element.type === 'tag' && element.name === 'figure';
isFigure && (wrapper = null);
const isNonWhitespaceTextNode = element.type === 'text' && /^\s*$/.test(element.data) === false;
isNonWhitespaceTextNode && (wrapper = append({
dom,
wrapper,
element
}));
const isInlineNode = element.type === 'tag' && blockNodes.includes(element.name) === false;
isInlineNode && (wrapper = append({
dom,
wrapper,
element
}));
const hasUpdate = isNonWhitespaceTextNode || isInlineNode;
if (hasUpdate) {
updates[dotPath] = {
...widget,
content: dom('body').html()
};
}
});
}
if (Object.keys(updates).length) {
await self.apos.doc.db.updateOne(
{ _id: doc._id },
{ $set: updates }
);
self.apos.util.log(`Document ${doc._id} rich-texts have been updated`);
}
};
const isConfirmed = await confirm(argv.confirm);
return isConfirmed && self.apos.migration.eachWidget({}, iterator);
}
}
};
}
};