ghost
Version:
The professional publishing platform
215 lines (188 loc) • 8.65 kB
JavaScript
// # Amp Content Helper
// Usage: `{{amp_content}}`
//
// Turns content html into a safestring so that the user doesn't have to
// escape it or tell handlebars to leave it alone with a triple-brace.
//
// Converts normal HTML into AMP HTML with Amperize module and uses a cache to return it from
// there if available. The cacheId is a combination of `updated_at` and the `slug`.
const {DateTime} = require('luxon');
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const {SafeString} = require('../../../../services/handlebars');
const amperizeCache = {};
let allowedAMPTags = [];
let allowedAMPAttributes = {};
let amperize = null;
let ampHTML = '';
let cleanHTML = '';
allowedAMPTags = ['html', 'body', 'article', 'section', 'nav', 'aside', 'h1', 'h2',
'h3', 'h4', 'h5', 'h6', 'header', 'footer', 'address', 'p', 'hr',
'pre', 'blockquote', 'ol', 'ul', 'li', 'dl', 'dt', 'dd', 'figure',
'figcaption', 'div', 'main', 'a', 'em', 'strong', 'small', 's', 'cite',
'q', 'dfn', 'abbr', 'data', 'time', 'code', 'var', 'samp', 'kbd', 'sub',
'sup', 'i', 'b', 'u', 'mark', 'ruby', 'rb', 'rt', 'rtc', 'rp', 'bdi',
'bdo', 'span', 'br', 'wbr', 'ins', 'del', 'source', 'track', 'svg', 'g',
'path', 'glyph', 'glyphref', 'marker', 'view', 'circle', 'line', 'polygon',
'polyline', 'rect', 'text', 'textpath', 'tref', 'tspan', 'clippath',
'filter', 'lineargradient', 'radialgradient', 'mask', 'pattern', 'vkern',
'hkern', 'defs', 'stop', 'use', 'foreignobject', 'symbol', 'desc', 'title',
'table', 'caption', 'colgroup', 'col', 'tbody', 'thead', 'tfoot', 'tr', 'td',
'th', 'button', 'noscript', 'acronym', 'center', 'dir', 'hgroup', 'listing',
'multicol', 'nextid', 'nobr', 'spacer', 'strike', 'tt', 'xmp', 'amp-img',
'amp-video', 'amp-ad', 'amp-embed', 'amp-anim', 'amp-iframe', 'amp-youtube',
'amp-pixel', 'amp-audio', 'O:P'];
allowedAMPAttributes = {
'*': ['itemid', 'itemprop', 'itemref', 'itemscope', 'itemtype', 'accesskey', 'class', 'dir', 'draggable',
'id', 'lang', 'tabindex', 'title', 'translate', 'aria-*', 'role', 'placeholder', 'fallback', 'lightbox',
'overflow', 'amp-access', 'amp-access-*', 'i-amp-access-id', 'data-*'],
h1: ['align'],
h2: ['align'],
h3: ['align'],
h4: ['align'],
h5: ['align'],
h6: ['align'],
p: ['align'],
blockquote: ['align'],
ol: ['reversed', 'start', 'type'],
li: ['value'],
div: ['align'],
a: ['href', 'hreflang', 'rel', 'role', 'tabindex', 'target', 'download', 'media', 'type', 'border', 'name'],
time: ['datetime'],
bdo: ['dir'],
ins: ['datetime'],
del: ['datetime'],
source: ['src', 'srcset', 'sizes', 'media', 'type', 'kind', 'label', 'srclang'],
track: ['src', 'default', 'kind', 'label', 'srclang'],
svg: ['*'],
g: ['*'],
glyph: ['*'],
glyphref: ['*'],
marker: ['*'],
path: ['*'],
view: ['*'],
circle: ['*'],
line: ['*'],
polygon: ['*'],
polyline: ['*'],
rect: ['*'],
text: ['*'],
textpath: ['*'],
tref: ['*'],
tspan: ['*'],
clippath: ['*'],
filter: ['*'],
hkern: ['*'],
lineargradient: ['*'],
mask: ['*'],
pattern: ['*'],
radialgradient: ['*'],
stop: ['*'],
vkern: ['*'],
defs: ['*'],
symbol: ['*'],
use: ['*'],
foreignobject: ['*'],
desc: ['*'],
title: ['*'],
table: ['sortable', 'align', 'border', 'bgcolor', 'cellpadding', 'cellspacing', 'width'],
colgroup: ['span'],
col: ['span'],
tr: ['align', 'bgcolor', 'height', 'valign'],
td: ['align', 'bgcolor', 'height', 'valign', 'colspan', 'headers', 'rowspan'],
th: ['align', 'bgcolor', 'height', 'valign', 'colspan', 'headers', 'rowspan', 'abbr', 'scope', 'sorted'],
button: ['disabled', 'name', 'role', 'tabindex', 'type', 'value', 'formtarget'],
// built ins
'amp-img': ['media', 'noloading', 'alt', 'attribution', 'placeholder', 'src', 'srcset', 'width', 'height', 'layout'],
'amp-pixel': ['src'],
'amp-video': ['src', 'srcset', 'media', 'noloading', 'width', 'height', 'layout', 'alt', 'attribution',
'autoplay', 'controls', 'loop', 'muted', 'poster', 'preload'],
'amp-embed': ['media', 'noloading', 'width', 'height', 'layout', 'type', 'data-*', 'json'],
'amp-ad': ['media', 'noloading', 'width', 'height', 'layout', 'type', 'data-*', 'json'],
// extended components we support
'amp-anim': ['media', 'noloading', 'alt', 'attribution', 'placeholder', 'src', 'srcset', 'width', 'height', 'layout'],
'amp-audio': ['src', 'width', 'height', 'autoplay', 'loop', 'muted', 'controls'],
'amp-iframe': ['src', 'srcdoc', 'width', 'height', 'layout', 'frameborder', 'allowfullscreen', 'allowtransparency',
'sandbox', 'referrerpolicy'],
'amp-youtube': ['src', 'layout', 'frameborder', 'autoplay', 'loop', 'data-videoid', 'data-live-channelid', 'width', 'height']
};
function getAmperizeHTML(html, post) {
if (!html) {
return;
}
let Amperize = require('amperize');
amperize = amperize || new Amperize();
let cacheDateTime;
let postDateTime;
if (amperizeCache[post.id]) {
const {updated_at: ampCacheUpdatedAt} = amperizeCache[post.id];
const {updated_at: postUpdatedAt} = post;
cacheDateTime = DateTime.fromJSDate(new Date(ampCacheUpdatedAt));
postDateTime = DateTime.fromJSDate(new Date(postUpdatedAt));
}
if (!amperizeCache[post.id] || cacheDateTime.diff(postDateTime).valueOf() < 0) {
return new Promise((resolve) => {
amperize.parse(html, (err, res) => {
if (err) {
if (err.src) {
// This is a valid 500 GhostError because it means the amperize parser is unable to handle some Ghost HTML.
logging.error(new errors.InternalServerError({
message: `AMP HTML couldn't be parsed: ${err.src}`,
code: 'AMP_PARSER_ERROR',
err: err,
context: post.url,
help: 'Please share this error on GitHub or https://forum.ghost.org'
}));
} else {
logging.error(new errors.InternalServerError({err, code: 'AMP_PARSER_ERROR'}));
}
// save it in cache to prevent multiple calls to Amperize until
// content is updated.
amperizeCache[post.id] = {updated_at: post.updated_at, amp: html};
// return the original html on an error
return resolve(html);
}
amperizeCache[post.id] = {updated_at: post.updated_at, amp: res};
return resolve(amperizeCache[post.id].amp);
});
});
}
return Promise.resolve(amperizeCache[post.id].amp);
}
module.exports = async function amp_content() { // eslint-disable-line camelcase
let sanitizeHtml = require('sanitize-html');
let cheerio = require('cheerio');
try {
const response = await getAmperizeHTML(this.html, this);
let $ = null;
// our Amperized HTML
ampHTML = response ?? '';
// Use cheerio to traverse through HTML and make little clean-ups
$ = cheerio.load(ampHTML);
// We have to remove source children in video, as source is allowed for audio,
// but causes validation errors in video, because video will be stripped out.
// @TODO: remove this, when Amperize support video transform
$('video').children('source').remove();
$('video').children('track').remove();
// Case: AMP parsing failed and we returned the regular HTML,
// then we have to remove remaining, invalid HTML tags.
$('audio').children('source').remove();
$('audio').children('track').remove();
$('amp-youtube').attr('layout', 'responsive');
$('amp-youtube').attr('height', '350');
$('amp-youtube').attr('width', '600');
ampHTML = $.html();
// @TODO: remove this, when Amperize supports HTML sanitizing
cleanHTML = sanitizeHtml(ampHTML, {
allowedTags: allowedAMPTags,
allowedAttributes: allowedAMPAttributes,
selfClosing: ['source', 'track', 'br']
});
return new SafeString(cleanHTML);
} catch (error) {
logging.error(error);
// Return an empty safe string
return new SafeString('');
}
};
module.exports.async = true;