mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-18 02:21:47 -05:00
Added test that validates output HTML of email template (#15365)
refs https://github.com/TryGhost/Team/issues/1871 This commit adds a test to the serialize method of `post-emaiserializer`. It checks whether the generated email HTML is valid and standard HTML5 and that all properties are escaped. To do this validation, I depend on the new `html-validate` dev dependency. Just parsing the HTML with a HTML parser is not enough to guarantee that the HTML is okay. Apart from that this fixes: - Removed the sanitizeHTML method and replaced it with normal HTML escaping. We don't want to allow any HTML in the escaped fields. Whereas `sanitizeHTML` still allows valid HTML, but we don't want that and want the same behaviour as on the site. E.g., a post with a title `All your need to know about the <br /> tag` should actually render the same title and non-html content, being `All your need to know about the <br /> tag` - The file, nft and audio card didn't (always) escape the injected HTML fields (new version @tryghost/kg-default-cards) - `@tryghost/string` is bumped because it contains the new escapeHtml method
This commit is contained in:
parent
11cbfcb0b6
commit
4534b693e4
10 changed files with 684 additions and 520 deletions
|
@ -44,14 +44,14 @@
|
|||
"@tryghost/color-utils": "0.1.20",
|
||||
"@tryghost/ember-promise-modals": "2.0.1",
|
||||
"@tryghost/helpers": "1.1.72",
|
||||
"@tryghost/kg-clean-basic-html": "2.2.14",
|
||||
"@tryghost/kg-parser-plugins": "2.12.0",
|
||||
"@tryghost/kg-clean-basic-html": "2.2.16",
|
||||
"@tryghost/kg-parser-plugins": "2.12.1",
|
||||
"@tryghost/limit-service": "1.2.3",
|
||||
"@tryghost/members-csv": "0.0.0",
|
||||
"@tryghost/mobiledoc-kit": "0.12.5-ghost.2",
|
||||
"@tryghost/nql": "0.9.2",
|
||||
"@tryghost/nql-lang": "0.3.2",
|
||||
"@tryghost/string": "0.1.27",
|
||||
"@tryghost/string": "0.2.0",
|
||||
"@tryghost/timezone-data": "0.2.71",
|
||||
"autoprefixer": "9.8.6",
|
||||
"babel-eslint": "10.1.0",
|
||||
|
@ -174,4 +174,4 @@
|
|||
"path-browserify": "1.0.1",
|
||||
"webpack": "5.74.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,427 +17,430 @@ const urlService = require('../../services/url');
|
|||
|
||||
const ALLOWED_REPLACEMENTS = ['first_name'];
|
||||
|
||||
// Format a full html document ready for email by inlining CSS, adjusting links,
|
||||
// and performing any client-specific fixes
|
||||
const formatHtmlForEmail = function formatHtmlForEmail(html) {
|
||||
const juiceOptions = {inlinePseudoElements: true};
|
||||
const PostEmailSerializer = {
|
||||
|
||||
// Format a full html document ready for email by inlining CSS, adjusting links,
|
||||
// and performing any client-specific fixes
|
||||
formatHtmlForEmail(html) {
|
||||
const juiceOptions = {inlinePseudoElements: true};
|
||||
|
||||
const juice = require('juice');
|
||||
let juicedHtml = juice(html, juiceOptions);
|
||||
const juice = require('juice');
|
||||
let juicedHtml = juice(html, juiceOptions);
|
||||
|
||||
// convert juiced HTML to a DOM-like interface for further manipulation
|
||||
// happens after inlining of CSS so we can change element types without worrying about styling
|
||||
// convert juiced HTML to a DOM-like interface for further manipulation
|
||||
// happens after inlining of CSS so we can change element types without worrying about styling
|
||||
|
||||
const cheerio = require('cheerio');
|
||||
const _cheerio = cheerio.load(juicedHtml);
|
||||
const cheerio = require('cheerio');
|
||||
const _cheerio = cheerio.load(juicedHtml);
|
||||
|
||||
// force all links to open in new tab
|
||||
_cheerio('a').attr('target', '_blank');
|
||||
// convert figure and figcaption to div so that Outlook applies margins
|
||||
_cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
|
||||
// force all links to open in new tab
|
||||
_cheerio('a').attr('target', '_blank');
|
||||
// convert figure and figcaption to div so that Outlook applies margins
|
||||
_cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
|
||||
|
||||
juicedHtml = _cheerio.html();
|
||||
juicedHtml = _cheerio.html();
|
||||
|
||||
// Fix any unsupported chars in Outlook
|
||||
juicedHtml = juicedHtml.replace(/'/g, ''');
|
||||
// Fix any unsupported chars in Outlook
|
||||
juicedHtml = juicedHtml.replace(/'/g, ''');
|
||||
|
||||
return juicedHtml;
|
||||
};
|
||||
return juicedHtml;
|
||||
},
|
||||
|
||||
const getSite = () => {
|
||||
const publicSettings = settingsCache.getPublic();
|
||||
return Object.assign({}, publicSettings, {
|
||||
url: urlUtils.urlFor('home', true),
|
||||
iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* createUnsubscribeUrl
|
||||
*
|
||||
* Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
|
||||
* In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
|
||||
*
|
||||
* @param {string} uuid post uuid
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.newsletterUuid] newsletter uuid
|
||||
* @param {boolean} [options.comments] Unsubscribe from comment emails
|
||||
*/
|
||||
const createUnsubscribeUrl = (uuid, options = {}) => {
|
||||
const siteUrl = urlUtils.getSiteUrl();
|
||||
const unsubscribeUrl = new URL(siteUrl);
|
||||
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
|
||||
if (uuid) {
|
||||
unsubscribeUrl.searchParams.set('uuid', uuid);
|
||||
} else {
|
||||
unsubscribeUrl.searchParams.set('preview', '1');
|
||||
}
|
||||
if (options.newsletterUuid) {
|
||||
unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
|
||||
}
|
||||
if (options.comments) {
|
||||
unsubscribeUrl.searchParams.set('comments', '1');
|
||||
}
|
||||
|
||||
return unsubscribeUrl.href;
|
||||
};
|
||||
|
||||
/**
|
||||
* createPostSignupUrl
|
||||
*
|
||||
* Takes a post object. Returns the url that should be used to signup from newsletter
|
||||
*
|
||||
* @param {Object} post post object
|
||||
*/
|
||||
const createPostSignupUrl = (post) => {
|
||||
let url = urlService.getUrlByResourceId(post.id, {absolute: true});
|
||||
|
||||
// For email-only posts, use site url as base
|
||||
if (post.status !== 'published' && url.match(/\/404\//)) {
|
||||
url = urlUtils.getSiteUrl();
|
||||
}
|
||||
|
||||
const signupUrl = new URL(url);
|
||||
signupUrl.hash = `/portal/signup`;
|
||||
|
||||
return signupUrl.href;
|
||||
};
|
||||
|
||||
// NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
|
||||
const serializePostModel = async (model) => {
|
||||
// fetch mobiledoc rather than html and plaintext so we can render email-specific contents
|
||||
const frame = {options: {context: {user: true}, formats: 'mobiledoc'}};
|
||||
const docName = 'posts';
|
||||
|
||||
await apiFramework
|
||||
.serializers
|
||||
.handle
|
||||
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
|
||||
|
||||
return frame.response[docName][0];
|
||||
};
|
||||
|
||||
// removes %% wrappers from unknown replacement strings in email content
|
||||
const normalizeReplacementStrings = (email) => {
|
||||
// we don't want to modify the email object in-place
|
||||
const emailContent = _.pick(email, ['html', 'plaintext']);
|
||||
|
||||
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
|
||||
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?\}/;
|
||||
|
||||
['html', 'plaintext'].forEach((format) => {
|
||||
emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
|
||||
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
|
||||
|
||||
if (match) {
|
||||
const {recipientProperty} = match.groups;
|
||||
|
||||
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
|
||||
// keeps wrapping %% for later replacement with real data
|
||||
return replacementMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// removes %% so output matches user supplied content
|
||||
return replacementStr;
|
||||
getSite() {
|
||||
const publicSettings = settingsCache.getPublic();
|
||||
return Object.assign({}, publicSettings, {
|
||||
url: urlUtils.urlFor('home', true),
|
||||
iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
return emailContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses email content and extracts an array of replacements with desired fallbacks
|
||||
*
|
||||
* @param {Object} email
|
||||
* @param {string} email.html
|
||||
* @param {string} email.plaintext
|
||||
*
|
||||
* @returns {Object[]} replacements
|
||||
*/
|
||||
const parseReplacements = (email) => {
|
||||
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
|
||||
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?\}/;
|
||||
|
||||
const replacements = [];
|
||||
|
||||
['html', 'plaintext'].forEach((format) => {
|
||||
let result;
|
||||
while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
|
||||
const [replacementMatch, replacementStr] = result;
|
||||
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
|
||||
|
||||
if (match) {
|
||||
const {recipientProperty, fallback} = match.groups;
|
||||
|
||||
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
|
||||
const id = `replacement_${replacements.length + 1}`;
|
||||
|
||||
replacements.push({
|
||||
format,
|
||||
id,
|
||||
match: replacementMatch,
|
||||
recipientProperty: `member_${recipientProperty}`,
|
||||
fallback
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return replacements;
|
||||
};
|
||||
|
||||
const getTemplateSettings = async (newsletter) => {
|
||||
const accentColor = settingsCache.get('accent_color');
|
||||
const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
|
||||
const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
|
||||
|
||||
const templateSettings = {
|
||||
headerImage: newsletter.get('header_image'),
|
||||
showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
|
||||
showHeaderTitle: newsletter.get('show_header_title'),
|
||||
showFeatureImage: newsletter.get('show_feature_image'),
|
||||
titleFontCategory: newsletter.get('title_font_category'),
|
||||
titleAlignment: newsletter.get('title_alignment'),
|
||||
bodyFontCategory: newsletter.get('body_font_category'),
|
||||
showBadge: newsletter.get('show_badge'),
|
||||
footerContent: newsletter.get('footer_content'),
|
||||
showHeaderName: newsletter.get('show_header_name'),
|
||||
accentColor,
|
||||
adjustedAccentColor,
|
||||
adjustedAccentContrastColor
|
||||
};
|
||||
|
||||
if (templateSettings.headerImage) {
|
||||
if (isUnsplashImage(templateSettings.headerImage)) {
|
||||
// Unsplash images have a minimum size so assuming 1200px is safe
|
||||
const unsplashUrl = new URL(templateSettings.headerImage);
|
||||
unsplashUrl.searchParams.set('w', '1200');
|
||||
|
||||
templateSettings.headerImage = unsplashUrl.href;
|
||||
templateSettings.headerImageWidth = 600;
|
||||
/**
|
||||
* createUnsubscribeUrl
|
||||
*
|
||||
* Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
|
||||
* In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
|
||||
*
|
||||
* @param {string} uuid post uuid
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.newsletterUuid] newsletter uuid
|
||||
* @param {boolean} [options.comments] Unsubscribe from comment emails
|
||||
*/
|
||||
createUnsubscribeUrl(uuid, options = {}) {
|
||||
const siteUrl = urlUtils.getSiteUrl();
|
||||
const unsubscribeUrl = new URL(siteUrl);
|
||||
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
|
||||
if (uuid) {
|
||||
unsubscribeUrl.searchParams.set('uuid', uuid);
|
||||
} else {
|
||||
const {imageSize} = require('../../lib/image');
|
||||
try {
|
||||
const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
|
||||
unsubscribeUrl.searchParams.set('preview', '1');
|
||||
}
|
||||
if (options.newsletterUuid) {
|
||||
unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
|
||||
}
|
||||
if (options.comments) {
|
||||
unsubscribeUrl.searchParams.set('comments', '1');
|
||||
}
|
||||
|
||||
if (size.width >= 600) {
|
||||
// keep original image, just set a fixed width
|
||||
templateSettings.headerImageWidth = 600;
|
||||
return unsubscribeUrl.href;
|
||||
},
|
||||
|
||||
/**
|
||||
* createPostSignupUrl
|
||||
*
|
||||
* Takes a post object. Returns the url that should be used to signup from newsletter
|
||||
*
|
||||
* @param {Object} post post object
|
||||
*/
|
||||
createPostSignupUrl(post) {
|
||||
let url = urlService.getUrlByResourceId(post.id, {absolute: true});
|
||||
|
||||
// For email-only posts, use site url as base
|
||||
if (post.status !== 'published' && url.match(/\/404\//)) {
|
||||
url = urlUtils.getSiteUrl();
|
||||
}
|
||||
|
||||
const signupUrl = new URL(url);
|
||||
signupUrl.hash = `/portal/signup`;
|
||||
|
||||
return signupUrl.href;
|
||||
},
|
||||
|
||||
// NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
|
||||
async serializePostModel(model) {
|
||||
// fetch mobiledoc rather than html and plaintext so we can render email-specific contents
|
||||
const frame = {options: {context: {user: true}, formats: 'mobiledoc'}};
|
||||
const docName = 'posts';
|
||||
|
||||
await apiFramework
|
||||
.serializers
|
||||
.handle
|
||||
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
|
||||
|
||||
return frame.response[docName][0];
|
||||
},
|
||||
|
||||
// removes %% wrappers from unknown replacement strings in email content
|
||||
normalizeReplacementStrings(email) {
|
||||
// we don't want to modify the email object in-place
|
||||
const emailContent = _.pick(email, ['html', 'plaintext']);
|
||||
|
||||
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
|
||||
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?\}/;
|
||||
|
||||
['html', 'plaintext'].forEach((format) => {
|
||||
emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
|
||||
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
|
||||
|
||||
if (match) {
|
||||
const {recipientProperty} = match.groups;
|
||||
|
||||
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
|
||||
// keeps wrapping %% for later replacement with real data
|
||||
return replacementMatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalContentImage(templateSettings.headerImage, urlUtils.getSiteUrl())) {
|
||||
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
|
||||
templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
|
||||
// removes %% so output matches user supplied content
|
||||
return replacementStr;
|
||||
});
|
||||
});
|
||||
|
||||
return emailContent;
|
||||
},
|
||||
|
||||
/**
|
||||
* Parses email content and extracts an array of replacements with desired fallbacks
|
||||
*
|
||||
* @param {Object} email
|
||||
* @param {string} email.html
|
||||
* @param {string} email.plaintext
|
||||
*
|
||||
* @returns {Object[]} replacements
|
||||
*/
|
||||
parseReplacements(email) {
|
||||
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
|
||||
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?\}/;
|
||||
|
||||
const replacements = [];
|
||||
|
||||
['html', 'plaintext'].forEach((format) => {
|
||||
let result;
|
||||
while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
|
||||
const [replacementMatch, replacementStr] = result;
|
||||
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
|
||||
|
||||
if (match) {
|
||||
const {recipientProperty, fallback} = match.groups;
|
||||
|
||||
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
|
||||
const id = `replacement_${replacements.length + 1}`;
|
||||
|
||||
replacements.push({
|
||||
format,
|
||||
id,
|
||||
match: replacementMatch,
|
||||
recipientProperty: `member_${recipientProperty}`,
|
||||
fallback
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return replacements;
|
||||
},
|
||||
|
||||
async getTemplateSettings(newsletter) {
|
||||
const accentColor = settingsCache.get('accent_color');
|
||||
const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
|
||||
const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
|
||||
|
||||
const templateSettings = {
|
||||
headerImage: newsletter.get('header_image'),
|
||||
showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
|
||||
showHeaderTitle: newsletter.get('show_header_title'),
|
||||
showFeatureImage: newsletter.get('show_feature_image'),
|
||||
titleFontCategory: newsletter.get('title_font_category'),
|
||||
titleAlignment: newsletter.get('title_alignment'),
|
||||
bodyFontCategory: newsletter.get('body_font_category'),
|
||||
showBadge: newsletter.get('show_badge'),
|
||||
footerContent: newsletter.get('footer_content'),
|
||||
showHeaderName: newsletter.get('show_header_name'),
|
||||
accentColor,
|
||||
adjustedAccentColor,
|
||||
adjustedAccentContrastColor
|
||||
};
|
||||
|
||||
if (templateSettings.headerImage) {
|
||||
if (isUnsplashImage(templateSettings.headerImage)) {
|
||||
// Unsplash images have a minimum size so assuming 1200px is safe
|
||||
const unsplashUrl = new URL(templateSettings.headerImage);
|
||||
unsplashUrl.searchParams.set('w', '1200');
|
||||
|
||||
templateSettings.headerImage = unsplashUrl.href;
|
||||
templateSettings.headerImageWidth = 600;
|
||||
} else {
|
||||
const {imageSize} = require('../../lib/image');
|
||||
try {
|
||||
const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
|
||||
|
||||
if (size.width >= 600) {
|
||||
// keep original image, just set a fixed width
|
||||
templateSettings.headerImageWidth = 600;
|
||||
}
|
||||
|
||||
if (isLocalContentImage(templateSettings.headerImage, urlUtils.getSiteUrl())) {
|
||||
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
|
||||
templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
|
||||
}
|
||||
} catch (err) {
|
||||
// log and proceed. Using original header image without fixed width isn't fatal.
|
||||
logging.error(err);
|
||||
}
|
||||
} catch (err) {
|
||||
// log and proceed. Using original header image without fixed width isn't fatal.
|
||||
logging.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return templateSettings;
|
||||
return templateSettings;
|
||||
},
|
||||
|
||||
async serialize(postModel, newsletter, options = {isBrowserPreview: false}) {
|
||||
const post = await this.serializePostModel(postModel);
|
||||
|
||||
const timezone = settingsCache.get('timezone');
|
||||
const momentDate = post.published_at ? moment(post.published_at) : moment();
|
||||
post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
|
||||
|
||||
if (post.authors) {
|
||||
if (post.authors.length <= 2) {
|
||||
post.authors = post.authors.map(author => author.name).join(' & ');
|
||||
} else if (post.authors.length > 2) {
|
||||
post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
|
||||
}
|
||||
}
|
||||
|
||||
if (post.posts_meta) {
|
||||
post.email_subject = post.posts_meta.email_subject;
|
||||
}
|
||||
|
||||
// we use post.excerpt as a hidden piece of text that is picked up by some email
|
||||
// clients as a "preview" when listing emails. Our current plaintext/excerpt
|
||||
// generation outputs links as "Link [https://url/]" which isn't desired in the preview
|
||||
if (!post.custom_excerpt && post.excerpt) {
|
||||
post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
|
||||
}
|
||||
|
||||
post.html = mobiledocLib.mobiledocHtmlRenderer.render(
|
||||
JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
|
||||
);
|
||||
|
||||
// perform any email specific adjustments to the mobiledoc->HTML render output
|
||||
// body wrapper is required so we can get proper top-level selections
|
||||
const cheerio = require('cheerio');
|
||||
let _cheerio = cheerio.load(`<body>${post.html}</body>`);
|
||||
// remove leading/trailing HRs
|
||||
_cheerio(`
|
||||
body > hr:first-child,
|
||||
body > hr:last-child,
|
||||
body > div:first-child > hr:first-child,
|
||||
body > div:last-child > hr:last-child
|
||||
`).remove();
|
||||
post.html = _cheerio('body').html();
|
||||
|
||||
post.plaintext = htmlToPlaintext.email(post.html);
|
||||
|
||||
// Outlook will render feature images at full-size breaking the layout.
|
||||
// Content images fix this by rendering max 600px images - do the same for feature image here
|
||||
if (post.feature_image) {
|
||||
if (isUnsplashImage(post.feature_image)) {
|
||||
// Unsplash images have a minimum size so assuming 1200px is safe
|
||||
const unsplashUrl = new URL(post.feature_image);
|
||||
unsplashUrl.searchParams.set('w', '1200');
|
||||
|
||||
post.feature_image = unsplashUrl.href;
|
||||
post.feature_image_width = 600;
|
||||
} else {
|
||||
const {imageSize} = require('../../lib/image');
|
||||
try {
|
||||
const size = await imageSize.getImageSizeFromUrl(post.feature_image);
|
||||
|
||||
if (size.width >= 600) {
|
||||
// keep original image, just set a fixed width
|
||||
post.feature_image_width = 600;
|
||||
}
|
||||
|
||||
if (isLocalContentImage(post.feature_image, urlUtils.getSiteUrl())) {
|
||||
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
|
||||
post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
|
||||
}
|
||||
} catch (err) {
|
||||
// log and proceed. Using original feature_image without fixed width isn't fatal.
|
||||
logging.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const templateSettings = await this.getTemplateSettings(newsletter);
|
||||
|
||||
const render = template;
|
||||
|
||||
let htmlTemplate = render({post, site: this.getSite(), templateSettings, newsletter: newsletter.toJSON()});
|
||||
|
||||
if (options.isBrowserPreview) {
|
||||
const previewUnsubscribeUrl = this.createUnsubscribeUrl(null);
|
||||
htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
|
||||
}
|
||||
|
||||
// Clean up any unknown replacements strings to get our final content
|
||||
const {html, plaintext} = this.normalizeReplacementStrings({
|
||||
html: this.formatHtmlForEmail(htmlTemplate),
|
||||
plaintext: post.plaintext
|
||||
});
|
||||
const data = {
|
||||
subject: post.email_subject || post.title,
|
||||
html,
|
||||
plaintext
|
||||
};
|
||||
if (labs.isSet('newsletterPaywall')) {
|
||||
data.post = post;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* renderPaywallCTA
|
||||
*
|
||||
* outputs html for rendering paywall CTA in newsletter
|
||||
*
|
||||
* @param {Object} post Post Object
|
||||
*/
|
||||
renderPaywallCTA(post) {
|
||||
const accentColor = settingsCache.get('accent_color');
|
||||
const siteTitle = settingsCache.get('title') || 'Ghost';
|
||||
const signupUrl = this.createPostSignupUrl(post);
|
||||
|
||||
return `<div class="align-center" style="text-align: center;">
|
||||
<hr
|
||||
style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
|
||||
<h2
|
||||
style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
|
||||
Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
|
||||
<p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to all
|
||||
<span style="white-space: nowrap;">subscriber-only content.</span></p>
|
||||
<div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center"
|
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
|
||||
valign="top" bgcolor="${accentColor}">
|
||||
<a href="${signupUrl}"
|
||||
style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
|
||||
target="_blank">Subscribe
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
renderEmailForSegment(email, memberSegment) {
|
||||
const cheerio = require('cheerio');
|
||||
|
||||
const result = {...email};
|
||||
|
||||
/** Checks and hides content for newsletter behind paywall card
|
||||
* based on member's status and post access
|
||||
* Adds CTA in case content is hidden.
|
||||
*/
|
||||
if (labs.isSet('newsletterPaywall')) {
|
||||
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
|
||||
if (paywallIndex !== -1 && memberSegment && result.post) {
|
||||
let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
|
||||
const postVisiblity = result.post.visibility;
|
||||
|
||||
// For newsletter paywall, specific tiers visibility is considered on par to paid tiers
|
||||
result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
|
||||
|
||||
const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
|
||||
|
||||
if (!memberHasAccess) {
|
||||
const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
|
||||
result.html = result.html.slice(0, paywallIndex) + this.renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
|
||||
result.plaintext = htmlToPlaintext.excerpt(result.html);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const $ = cheerio.load(result.html);
|
||||
|
||||
$('[data-gh-segment]').get().forEach((node) => {
|
||||
if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
|
||||
$(node).remove();
|
||||
} else {
|
||||
// Getting rid of the attribute for a cleaner html output
|
||||
$(node).removeAttr('data-gh-segment');
|
||||
}
|
||||
});
|
||||
|
||||
result.html = this.formatHtmlForEmail($.html());
|
||||
result.plaintext = htmlToPlaintext.email(result.html);
|
||||
delete result.post;
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
const serialize = async (postModel, newsletter, options = {isBrowserPreview: false}) => {
|
||||
const post = await serializePostModel(postModel);
|
||||
|
||||
const timezone = settingsCache.get('timezone');
|
||||
const momentDate = post.published_at ? moment(post.published_at) : moment();
|
||||
post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
|
||||
|
||||
if (post.authors) {
|
||||
if (post.authors.length <= 2) {
|
||||
post.authors = post.authors.map(author => author.name).join(' & ');
|
||||
} else if (post.authors.length > 2) {
|
||||
post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
|
||||
}
|
||||
}
|
||||
|
||||
if (post.posts_meta) {
|
||||
post.email_subject = post.posts_meta.email_subject;
|
||||
}
|
||||
|
||||
// we use post.excerpt as a hidden piece of text that is picked up by some email
|
||||
// clients as a "preview" when listing emails. Our current plaintext/excerpt
|
||||
// generation outputs links as "Link [https://url/]" which isn't desired in the preview
|
||||
if (!post.custom_excerpt && post.excerpt) {
|
||||
post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
|
||||
}
|
||||
|
||||
post.html = mobiledocLib.mobiledocHtmlRenderer.render(
|
||||
JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
|
||||
);
|
||||
|
||||
// perform any email specific adjustments to the mobiledoc->HTML render output
|
||||
// body wrapper is required so we can get proper top-level selections
|
||||
const cheerio = require('cheerio');
|
||||
let _cheerio = cheerio.load(`<body>${post.html}</body>`);
|
||||
// remove leading/trailing HRs
|
||||
_cheerio(`
|
||||
body > hr:first-child,
|
||||
body > hr:last-child,
|
||||
body > div:first-child > hr:first-child,
|
||||
body > div:last-child > hr:last-child
|
||||
`).remove();
|
||||
post.html = _cheerio('body').html();
|
||||
|
||||
post.plaintext = htmlToPlaintext.email(post.html);
|
||||
|
||||
// Outlook will render feature images at full-size breaking the layout.
|
||||
// Content images fix this by rendering max 600px images - do the same for feature image here
|
||||
if (post.feature_image) {
|
||||
if (isUnsplashImage(post.feature_image)) {
|
||||
// Unsplash images have a minimum size so assuming 1200px is safe
|
||||
const unsplashUrl = new URL(post.feature_image);
|
||||
unsplashUrl.searchParams.set('w', '1200');
|
||||
|
||||
post.feature_image = unsplashUrl.href;
|
||||
post.feature_image_width = 600;
|
||||
} else {
|
||||
const {imageSize} = require('../../lib/image');
|
||||
try {
|
||||
const size = await imageSize.getImageSizeFromUrl(post.feature_image);
|
||||
|
||||
if (size.width >= 600) {
|
||||
// keep original image, just set a fixed width
|
||||
post.feature_image_width = 600;
|
||||
}
|
||||
|
||||
if (isLocalContentImage(post.feature_image, urlUtils.getSiteUrl())) {
|
||||
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
|
||||
post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
|
||||
}
|
||||
} catch (err) {
|
||||
// log and proceed. Using original feature_image without fixed width isn't fatal.
|
||||
logging.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const templateSettings = await getTemplateSettings(newsletter);
|
||||
|
||||
const render = template;
|
||||
|
||||
let htmlTemplate = render({post, site: getSite(), templateSettings, newsletter: newsletter.toJSON()});
|
||||
|
||||
if (options.isBrowserPreview) {
|
||||
const previewUnsubscribeUrl = createUnsubscribeUrl(null);
|
||||
htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
|
||||
}
|
||||
|
||||
// Clean up any unknown replacements strings to get our final content
|
||||
const {html, plaintext} = normalizeReplacementStrings({
|
||||
html: formatHtmlForEmail(htmlTemplate),
|
||||
plaintext: post.plaintext
|
||||
});
|
||||
const data = {
|
||||
subject: post.email_subject || post.title,
|
||||
html,
|
||||
plaintext
|
||||
};
|
||||
if (labs.isSet('newsletterPaywall')) {
|
||||
data.post = post;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* renderPaywallCTA
|
||||
*
|
||||
* outputs html for rendering paywall CTA in newsletter
|
||||
*
|
||||
* @param {Object} post Post Object
|
||||
*/
|
||||
|
||||
function renderPaywallCTA(post) {
|
||||
const accentColor = settingsCache.get('accent_color');
|
||||
const siteTitle = settingsCache.get('title') || 'Ghost';
|
||||
const signupUrl = createPostSignupUrl(post);
|
||||
|
||||
return `<div class="align-center" style="text-align: center;">
|
||||
<hr
|
||||
style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
|
||||
<h2
|
||||
style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
|
||||
Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
|
||||
<p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to all
|
||||
<span style="white-space: nowrap;">subscriber-only content.</span></p>
|
||||
<div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center"
|
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
|
||||
valign="top" bgcolor="${accentColor}">
|
||||
<a href="${signupUrl}"
|
||||
style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
|
||||
target="_blank">Subscribe
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderEmailForSegment(email, memberSegment) {
|
||||
const cheerio = require('cheerio');
|
||||
|
||||
const result = {...email};
|
||||
|
||||
/** Checks and hides content for newsletter behind paywall card
|
||||
* based on member's status and post access
|
||||
* Adds CTA in case content is hidden.
|
||||
*/
|
||||
if (labs.isSet('newsletterPaywall')) {
|
||||
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
|
||||
if (paywallIndex !== -1 && memberSegment && result.post) {
|
||||
let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
|
||||
const postVisiblity = result.post.visibility;
|
||||
|
||||
// For newsletter paywall, specific tiers visibility is considered on par to paid tiers
|
||||
result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
|
||||
|
||||
const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
|
||||
|
||||
if (!memberHasAccess) {
|
||||
const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
|
||||
result.html = result.html.slice(0, paywallIndex) + renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
|
||||
result.plaintext = htmlToPlaintext.excerpt(result.html);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const $ = cheerio.load(result.html);
|
||||
|
||||
$('[data-gh-segment]').get().forEach((node) => {
|
||||
if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
|
||||
$(node).remove();
|
||||
} else {
|
||||
// Getting rid of the attribute for a cleaner html output
|
||||
$(node).removeAttr('data-gh-segment');
|
||||
}
|
||||
});
|
||||
|
||||
result.html = formatHtmlForEmail($.html());
|
||||
result.plaintext = htmlToPlaintext.email(result.html);
|
||||
delete result.post;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serialize,
|
||||
createUnsubscribeUrl,
|
||||
createPostSignupUrl,
|
||||
renderEmailForSegment,
|
||||
parseReplacements,
|
||||
serialize: PostEmailSerializer.serialize.bind(PostEmailSerializer),
|
||||
createUnsubscribeUrl: PostEmailSerializer.createUnsubscribeUrl.bind(PostEmailSerializer),
|
||||
createPostSignupUrl: PostEmailSerializer.createPostSignupUrl.bind(PostEmailSerializer),
|
||||
renderEmailForSegment: PostEmailSerializer.renderEmailForSegment.bind(PostEmailSerializer),
|
||||
parseReplacements: PostEmailSerializer.parseReplacements.bind(PostEmailSerializer),
|
||||
// Export for tests
|
||||
_getTemplateSettings: getTemplateSettings
|
||||
_getTemplateSettings: PostEmailSerializer.getTemplateSettings.bind(PostEmailSerializer),
|
||||
_PostEmailSerializer: PostEmailSerializer
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const {escapeHtml: escape} = require('@tryghost/string');
|
||||
|
||||
/* eslint indent: warn, no-irregular-whitespace: warn */
|
||||
const iff = (cond, yes, no) => (cond ? yes : no);
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
|
||||
/**
|
||||
* @template {Object.<string, any>} Input
|
||||
|
@ -15,10 +16,7 @@ const sanitizeKeys = (obj, keys) => {
|
|||
for (const key of keysToSanitize) {
|
||||
if (typeof sanitized[key] === 'string') {
|
||||
// @ts-ignore
|
||||
sanitized[key] = sanitizeHtml(sanitized[key], {
|
||||
allowedTags: false,
|
||||
allowedAttributes: false
|
||||
});
|
||||
sanitized[key] = escape(sanitized[key]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,7 +26,7 @@ const sanitizeKeys = (obj, keys) => {
|
|||
module.exports = ({post, site, newsletter, templateSettings}) => {
|
||||
const date = new Date();
|
||||
const hasFeatureImageCaption = templateSettings.showFeatureImage && post.feature_image && post.feature_image_caption;
|
||||
const cleanPost = sanitizeKeys(post, ['title', 'excerpt', 'authors', 'feature_image_alt', 'feature_image_caption']);
|
||||
const cleanPost = sanitizeKeys(post, ['url', 'published_at', 'title', 'excerpt', 'authors', 'feature_image', 'feature_image_alt', 'feature_image_caption']);
|
||||
const cleanSite = sanitizeKeys(site, ['title']);
|
||||
const cleanNewsletter = sanitizeKeys(newsletter, ['name']);
|
||||
|
||||
|
@ -1181,7 +1179,6 @@ ${ templateSettings.showBadge ? `
|
|||
<td> </td>
|
||||
<td class="container">
|
||||
<div class="content">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
|
||||
|
||||
|
@ -1189,7 +1186,6 @@ ${ templateSettings.showBadge ? `
|
|||
<tr>
|
||||
<td class="wrapper">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
|
||||
${ templateSettings.headerImage ? `
|
||||
<tr>
|
||||
<td class="header-image" width="100%" align="center"><img src="${templateSettings.headerImage}"${templateSettings.headerImageWidth ? ` width="${templateSettings.headerImageWidth}"` : ''}></td>
|
||||
|
|
|
@ -112,7 +112,7 @@
|
|||
"@tryghost/social-urls": "0.1.33",
|
||||
"@tryghost/staff-service": "0.0.0",
|
||||
"@tryghost/stats-service": "0.3.0",
|
||||
"@tryghost/string": "0.1.27",
|
||||
"@tryghost/string": "0.2.0",
|
||||
"@tryghost/tpl": "0.1.18",
|
||||
"@tryghost/update-check-service": "0.0.0",
|
||||
"@tryghost/url-utils": "4.0.3",
|
||||
|
@ -178,7 +178,7 @@
|
|||
"xml": "1.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tryghost/html-to-mobiledoc": "1.8.11",
|
||||
"@tryghost/html-to-mobiledoc": "1.8.12",
|
||||
"sqlite3": "5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -193,6 +193,7 @@
|
|||
"grunt": "1.5.3",
|
||||
"grunt-contrib-symlink": "1.0.0",
|
||||
"grunt-shell": "4.0.0",
|
||||
"html-validate": "^7.3.3",
|
||||
"inquirer": "8.2.4",
|
||||
"jwks-rsa": "2.1.4",
|
||||
"mocha": "10.0.0",
|
||||
|
|
|
@ -350,7 +350,6 @@ table.body figcaption a {
|
|||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\"> </td>
|
||||
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
|
||||
|
||||
|
@ -358,7 +357,6 @@ table.body figcaption a {
|
|||
<tr>
|
||||
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -470,7 +468,7 @@ exports[`Email Preview API Read can read post email preview with email card and
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "18192",
|
||||
"content-length": "18188",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
|
@ -738,7 +736,6 @@ table.body figcaption a {
|
|||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\"> </td>
|
||||
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
|
||||
|
||||
|
@ -746,7 +743,6 @@ table.body figcaption a {
|
|||
<tr>
|
||||
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -874,7 +870,7 @@ exports[`Email Preview API Read can read post email preview with fields 2: [head
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "23017",
|
||||
"content-length": "23013",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
|
@ -1172,7 +1168,6 @@ table.body figcaption a {
|
|||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\"> </td>
|
||||
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
|
||||
|
||||
|
@ -1180,7 +1175,6 @@ table.body figcaption a {
|
|||
<tr>
|
||||
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1286,7 +1280,7 @@ exports[`Email Preview API Read has custom content transformations for email com
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "17954",
|
||||
"content-length": "17950",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
|
@ -1554,7 +1548,6 @@ table.body figcaption a {
|
|||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\"> </td>
|
||||
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
|
||||
|
||||
|
@ -1562,7 +1555,6 @@ table.body figcaption a {
|
|||
<tr>
|
||||
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
|
||||
|
||||
<tr>
|
||||
<td class=\\"header-image\\" width=\\"100%\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; padding-top: 16px;\\" valign=\\"top\\"><img src=\\"http://127.0.0.1:2369/content/images/2022/05/test.jpg\\" style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%;\\"></td>
|
||||
|
@ -1672,7 +1664,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "18320",
|
||||
"content-length": "18316",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
|
@ -2326,7 +2318,6 @@ table.body figcaption a {
|
|||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\"> </td>
|
||||
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
|
||||
|
||||
|
@ -2334,7 +2325,6 @@ table.body figcaption a {
|
|||
<tr>
|
||||
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
|
||||
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
|
||||
|
||||
<tr>
|
||||
<td class=\\"header-image\\" width=\\"100%\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; vertical-align: top; color: #15212A; padding-top: 16px;\\" valign=\\"top\\"><img src=\\"http://127.0.0.1:2369/content/images/2022/05/test.jpg\\" style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%;\\"></td>
|
||||
|
@ -2444,7 +2434,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 2: [headers
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "18320",
|
||||
"content-length": "18316",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
const should = require('should');
|
||||
const assert = require('assert');
|
||||
const sinon = require('sinon');
|
||||
const settingsCache = require('../../../../../core/shared/settings-cache');
|
||||
const models = require('../../../../../core/server/models');
|
||||
const urlUtils = require('../../../../../core/shared/url-utils');
|
||||
const urlService = require('../../../../../core/server/services/url');
|
||||
const labs = require('../../../../../core/shared/labs');
|
||||
const {parseReplacements, renderEmailForSegment, serialize, _getTemplateSettings, createUnsubscribeUrl, createPostSignupUrl, _PostEmailSerializer} = require('../../../../../core/server/services/mega/post-email-serializer');
|
||||
|
||||
const {parseReplacements, renderEmailForSegment, _getTemplateSettings, createUnsubscribeUrl, createPostSignupUrl} = require('../../../../../core/server/services/mega/post-email-serializer');
|
||||
function assertKeys(object, keys) {
|
||||
assert.deepStrictEqual(Object.keys(object).sort(), keys.sort());
|
||||
}
|
||||
|
||||
describe('Post Email Serializer', function () {
|
||||
it('creates replacement pattern for valid format and value', function () {
|
||||
|
@ -18,12 +21,12 @@ describe('Post Email Serializer', function () {
|
|||
plaintext
|
||||
});
|
||||
|
||||
replaced.length.should.equal(2);
|
||||
replaced[0].format.should.equal('html');
|
||||
replaced[0].recipientProperty.should.equal('member_first_name');
|
||||
assert.equal(replaced.length, 2);
|
||||
assert.equal(replaced[0].format, 'html');
|
||||
assert.equal(replaced[0].recipientProperty, 'member_first_name');
|
||||
|
||||
replaced[1].format.should.equal('plaintext');
|
||||
replaced[1].recipientProperty.should.equal('member_first_name');
|
||||
assert.equal(replaced[1].format, 'plaintext');
|
||||
assert.equal(replaced[1].recipientProperty, 'member_first_name');
|
||||
});
|
||||
|
||||
it('does not create replacements for unsupported variable names', function () {
|
||||
|
@ -35,7 +38,129 @@ describe('Post Email Serializer', function () {
|
|||
plaintext
|
||||
});
|
||||
|
||||
replaced.length.should.equal(0);
|
||||
assert.equal(replaced.length, 0);
|
||||
});
|
||||
|
||||
describe('serialize', function () {
|
||||
it('should output valid HTML and escape HTML characters in mobiledoc', async function () {
|
||||
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
|
||||
return {
|
||||
// This is not realistic, but just to test escaping
|
||||
url: 'https://testpost.com/t&es<3t-post"</body>/',
|
||||
title: 'This is\' a blog po"st test <3</body>',
|
||||
excerpt: 'This is a blog post test <3</body>',
|
||||
authors: 'This is a blog post test <3</body>',
|
||||
feature_image_alt: 'This is a blog post test <3</body>',
|
||||
feature_image_caption: 'This is a blog post test <3</body>',
|
||||
|
||||
// This is a markdown post with all cards that contain <3 in all fields + </body> tags
|
||||
// Note that some fields are already escaped in the frontend
|
||||
// eslint-disable-next-line
|
||||
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[['markdown',{markdown: 'This is a test markdown <3'}],['email',{html: '<p>Hey {first_name, "there"}, <3</p>'}],['button',{alignment: 'center',buttonText: 'Button <3 </body>',buttonUrl: 'I <3 test </body>'}],['embed',{url: 'https://opensea.io/assets/0x495f947276749ce646f68ac8c248420045cb7b5e/85405838485527185183935784047901288096962687962314908211909792283039451054081/',type: 'nft',metadata: {version: '1.0',title: '<3 LOVE PENGUIN #1',author_name: 'Yeex',author_url: 'https://opensea.io/Yeex',provider_name: 'OpenSea',provider_url: 'https://opensea.io',image_url: 'https://lh3.googleusercontent.com/d1N3L-OGHpCptdTHMJxqBJtIfZFAJ-CSv0ZDwsaQTtPqy7NHCt_GVmnQoWt0S8Pfug4EmQr4UdPjrYSjop1KTKJfLt6DWmjnXdLdrQ',creator_name: 'Yeex<3',description: '<3 LOVE PENGUIN #1',collection_name: '<3 LOVE PENGUIN'},caption: 'I <3 NFT captions'}],['callout',{calloutEmoji: '💡',calloutText: 'Callout test <3',backgroundColor: 'grey'}],['toggle',{heading: 'Toggle <3 header',content: '<p>Toggle <3 content</p>'}],['video',{loop: false,src: '__GHOST_URL__/content/media/2022/09/20220"829-<3ghost</body>.mp4',fileName: '20220829 ghos"t.mp4',width: 3072,height: 1920,duration: 221.5,mimeType: 'video/mp4',thumbnailSrc: '__GHOST_URL__/content/images/2022/09/media-th\'umbn"ail-<3</body>.jpg',thumbnailWidth: 3072,thumbnailHeight: 1920,caption: 'Test <3'}],['file',{loop: false,src: '__GHOST_URL__/content/files/2022/09/image<3</body>.png',fileName: 'image<3</body>.png',fileTitle: 'Image 1<3</body>',fileCaption: '<3</body>',fileSize: 152594}],['audio',{loop: false,src: '__GHOST_URL__/content/media/2022/09/sound<3</body>.mp3',title: 'I <3</body> audio files',duration: 27.252,mimeType: 'audio/mpeg'}],['file',{loop: false,src: '__GHOST_URL__/content/files/2022/09/image<3</body>.png',fileName: 'image<3</body>.png',fileTitle: 'I <3</body> file names',fileCaption: 'I <3</body> file descriptions',fileSize: 152594}],['embed',{caption: 'I <3 YouTube videos Lost On You',html: '<iframe width="200" height="113" src="https://www.youtube.com/embed/wDjeBNv6ip0?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen title="LP - Lost On You (Live)"></iframe>',metadata: {author_name: 'LP',author_url: 'https://www.youtube.com/c/LP',height: 113,provider_name: 'YouTube',provider_url: 'https://www.youtube.com/',thumbnail_height: 360,thumbnail_url: 'https://i.ytimg.com/vi/wDjeBNv6ip0/hqdefault.jpg',thumbnail_width: 480,title: 'LP - Lost On You <3 (Live)',version: '1.0',width: 200},type: 'video',url: 'https://www.youtube.com/watch?v=wDjeBNv6ip0&list=RDwDjeBNv6ip0&start_radio=1'}],['image',{src: '__GHOST_URL__/content/images/2022/09/"<3</body>.png',width: 780,height: 744,caption: 'i <3 images',alt: 'I <3</body> image alts'}],['gallery',{images: [{fileName: 'image<3</body>.png',row: 0,width: 780,height: 744,src: '__GHOST_URL__/content/images/2022/09/<3</body>.png'}],caption: 'I <3 image galleries'}],['hr',{}]],markups: [['a',['href','https://google.com/<3</body>']],['strong'],['em']],sections: [[1,'p',[[0,[],0,'This is a <3</body> post test']]],[10,0],[10,1],[10,2],[10,3],[10,4],[10,5],[10,6],[10,7],[10,8],[10,9],[10,10],[10,11],[10,12],[1,'p',[[0,[0],1,'https://google.com/<3</body>']]],[1,'p',[[0,[],0,'Paragraph test <3</body>']]],[1,'p',[[0,[1],1,'Bold paragraph test <3</body>']]],[1,'h3',[[0,[],0,'Heading test <3</body>']]],[1,'blockquote',[[0,[],0,'Quote test <3</body>']]],[1,'p',[[0,[2],1,'Italic test<3</body>']]],[1,'p',[]]],ghostVersion: '4.0'})
|
||||
};
|
||||
});
|
||||
const customSettings = {
|
||||
icon: 'icon2<3</body>',
|
||||
accent_color: '#000099',
|
||||
timezone: 'UTC'
|
||||
};
|
||||
|
||||
const settingsMock = sinon.stub(settingsCache, 'get');
|
||||
settingsMock.callsFake(function (key, options) {
|
||||
if (customSettings[key]) {
|
||||
return customSettings[key];
|
||||
}
|
||||
|
||||
return settingsMock.wrappedMethod.call(settingsCache, key, options);
|
||||
});
|
||||
const template = {
|
||||
name: 'My newsletter <3</body>',
|
||||
header_image: 'https://testpost.com/test-post</body>/',
|
||||
show_header_icon: true,
|
||||
show_header_title: true,
|
||||
show_feature_image: true,
|
||||
title_font_category: 'sans-serif',
|
||||
title_alignment: 'center',
|
||||
body_font_category: 'serif',
|
||||
show_badge: true,
|
||||
show_header_name: true,
|
||||
// Note: we don't need to check the footer content because this should contain valid HTML (not text)
|
||||
footer_content: '<span>Footer content with valid HTML</span>'
|
||||
};
|
||||
const newsletterMock = {
|
||||
get: function (key) {
|
||||
return template[key];
|
||||
},
|
||||
toJSON: function () {
|
||||
return template;
|
||||
}
|
||||
};
|
||||
|
||||
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
|
||||
|
||||
// Test if the email HTML is valid standard HTML5
|
||||
const {HtmlValidate} = require('html-validate');
|
||||
|
||||
const htmlvalidate = new HtmlValidate({
|
||||
extends: [
|
||||
'html-validate:document',
|
||||
'html-validate:standard'
|
||||
],
|
||||
rules: {
|
||||
// We need deprecated attrs for legacy tables in older email clients
|
||||
'no-deprecated-attr': 'off',
|
||||
|
||||
// Don't care that the first <hx> isn't <h1>
|
||||
'heading-level': 'off'
|
||||
},
|
||||
elements: [
|
||||
'html5',
|
||||
// By default, html-validate requires the 'lang' attribute on the <html> tag. We don't really want that for now.
|
||||
{
|
||||
html: {
|
||||
attributes: {
|
||||
lang: {
|
||||
required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const report = htmlvalidate.validateString(output.html);
|
||||
|
||||
// Improve debugging and show a snippet of the invalid HTML instead of just the line number or a huge HTML-dump
|
||||
const parsedErrors = [];
|
||||
|
||||
if (!report.valid) {
|
||||
const lines = output.html.split('\n');
|
||||
const messages = report.results[0].messages;
|
||||
|
||||
for (const item of messages) {
|
||||
if (item.severity !== 2) {
|
||||
// Ignore warnings
|
||||
continue;
|
||||
}
|
||||
const start = Math.max(item.line - 4, 0);
|
||||
const end = Math.min(item.line + 4, lines.length - 1);
|
||||
|
||||
const html = lines.slice(start, end).map(l => l.trim()).join('\n');
|
||||
parsedErrors.push(`${item.ruleId}: ${item.message}\n At line ${item.line}, col ${item.column}\n HTML-snippet:\n${html}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fail if invalid HTML
|
||||
assert.equal(report.valid, true, 'Expected valid HTML without warnings, got errors:\n' + parsedErrors.join('\n\n'));
|
||||
|
||||
// Check footer content is not escaped
|
||||
assert.equal(output.html.includes(template.footer_content), true);
|
||||
|
||||
// Check doesn't contain the non escaped string '<3'
|
||||
assert.equal(output.html.includes('<3'), false);
|
||||
|
||||
// Check if the template is rendered fully to the end (to make sure we acutally test all these mobiledocs)
|
||||
assert.equal(output.html.includes('Heading test <3'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderEmailForSegment', function () {
|
||||
|
@ -52,10 +177,10 @@ describe('Post Email Serializer', function () {
|
|||
|
||||
let output = renderEmailForSegment(email, 'status:free');
|
||||
|
||||
output.should.have.keys('html', 'plaintext', 'otherProperty');
|
||||
output.html.should.eql('<div>test</div>');
|
||||
output.plaintext.should.eql('test');
|
||||
output.otherProperty.should.eql(true); // Make sure to keep other properties
|
||||
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
|
||||
assert.equal(output.html, '<div>test</div>');
|
||||
assert.equal(output.plaintext, 'test');
|
||||
assert.equal(output.otherProperty, true); // Make sure to keep other properties
|
||||
});
|
||||
|
||||
it('should hide non matching member segments', function () {
|
||||
|
@ -68,15 +193,15 @@ describe('Post Email Serializer', function () {
|
|||
|
||||
let output = renderEmailForSegment(email, 'status:free');
|
||||
|
||||
output.should.have.keys('html', 'plaintext', 'otherProperty');
|
||||
output.html.should.eql('hello<div> free users!</div>');
|
||||
output.plaintext.should.eql('hello free users!');
|
||||
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
|
||||
assert.equal(output.html, 'hello<div> free users!</div>');
|
||||
assert.equal(output.plaintext, 'hello free users!');
|
||||
|
||||
output = renderEmailForSegment(email, 'status:-free');
|
||||
|
||||
output.should.have.keys('html', 'plaintext', 'otherProperty');
|
||||
output.html.should.eql('hello<div> paid users!</div>');
|
||||
output.plaintext.should.eql('hello paid users!');
|
||||
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
|
||||
assert.equal(output.html, 'hello<div> paid users!</div>');
|
||||
assert.equal(output.plaintext, 'hello paid users!');
|
||||
});
|
||||
|
||||
it('should hide all segments when the segment filter is empty', function () {
|
||||
|
@ -87,8 +212,8 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, null);
|
||||
output.html.should.equal('hello');
|
||||
output.plaintext.should.equal('hello');
|
||||
assert.equal(output.html, 'hello');
|
||||
assert.equal(output.plaintext, 'hello');
|
||||
});
|
||||
|
||||
it('should show paywall content for free members on paid posts', function () {
|
||||
|
@ -104,15 +229,16 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, 'status:free');
|
||||
output.html.should.containEql(`<p>Free content</p>`);
|
||||
output.html.should.containEql(`Subscribe to`);
|
||||
output.html.should.containEql(`https://site.com/blah/#/portal/signup`);
|
||||
output.html.should.not.containEql(`<p>Members content</p>`);
|
||||
assert(output.html.includes());
|
||||
assert(output.html.includes(`<p>Free content</p>`));
|
||||
assert(output.html.includes(`Subscribe to`));
|
||||
assert(output.html.includes(`https://site.com/blah/#/portal/signup`));
|
||||
assert(!output.html.includes(`<p>Members content</p>`));
|
||||
|
||||
output.plaintext.should.containEql(`Free content`);
|
||||
output.plaintext.should.containEql(`Subscribe to`);
|
||||
output.plaintext.should.containEql(`https://site.com/blah/#/portal/signup`);
|
||||
output.plaintext.should.not.containEql(`Members content`);
|
||||
assert(output.plaintext.includes(`Free content`));
|
||||
assert(output.plaintext.includes(`Subscribe to`));
|
||||
assert(output.plaintext.includes(`https://site.com/blah/#/portal/signup`));
|
||||
assert(!output.plaintext.includes(`Members content`));
|
||||
});
|
||||
|
||||
it('should show full cta for paid members on paid posts', function () {
|
||||
|
@ -128,8 +254,8 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, 'status:-free');
|
||||
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
output.plaintext.should.equal(`Free content\n\nMembers content`);
|
||||
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
assert.equal(output.plaintext, `Free content\n\nMembers content`);
|
||||
});
|
||||
|
||||
it('should show paywall content for free members on specific tier posts', function () {
|
||||
|
@ -145,15 +271,15 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, 'status:free');
|
||||
output.html.should.containEql(`<p>Free content</p>`);
|
||||
output.html.should.containEql(`Subscribe to`);
|
||||
output.html.should.containEql(`https://site.com/blah/#/portal/signup`);
|
||||
output.html.should.not.containEql(`<p>Members content</p>`);
|
||||
assert(output.html.includes(`<p>Free content</p>`));
|
||||
assert(output.html.includes(`Subscribe to`));
|
||||
assert(output.html.includes(`https://site.com/blah/#/portal/signup`));
|
||||
assert(!output.html.includes(`<p>Members content</p>`));
|
||||
|
||||
output.plaintext.should.containEql(`Free content`);
|
||||
output.plaintext.should.containEql(`Subscribe to`);
|
||||
output.plaintext.should.containEql(`https://site.com/blah/#/portal/signup`);
|
||||
output.plaintext.should.not.containEql(`Members content`);
|
||||
assert(output.plaintext.includes(`Free content`));
|
||||
assert(output.plaintext.includes(`Subscribe to`));
|
||||
assert(output.plaintext.includes(`https://site.com/blah/#/portal/signup`));
|
||||
assert(!output.plaintext.includes(`Members content`));
|
||||
});
|
||||
|
||||
it('should show full cta for paid members on specific tier posts', function () {
|
||||
|
@ -169,8 +295,8 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, 'status:-free');
|
||||
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
output.plaintext.should.equal(`Free content\n\nMembers content`);
|
||||
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
assert.equal(output.plaintext, `Free content\n\nMembers content`);
|
||||
});
|
||||
|
||||
it('should show full content for free members on free posts', function () {
|
||||
|
@ -186,8 +312,8 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, 'status:free');
|
||||
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
output.plaintext.should.equal(`Free content\n\nMembers content`);
|
||||
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
assert.equal(output.plaintext, `Free content\n\nMembers content`);
|
||||
});
|
||||
|
||||
it('should show full content for paid members on free posts', function () {
|
||||
|
@ -203,8 +329,8 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, 'status:-free');
|
||||
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
output.plaintext.should.equal(`Free content\n\nMembers content`);
|
||||
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
assert.equal(output.plaintext, `Free content\n\nMembers content`);
|
||||
});
|
||||
|
||||
it('should not crash on missing post for email with paywall', function () {
|
||||
|
@ -216,8 +342,8 @@ describe('Post Email Serializer', function () {
|
|||
};
|
||||
|
||||
let output = renderEmailForSegment(email, 'status:-free');
|
||||
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
output.plaintext.should.equal(`Free content\n\nMembers content`);
|
||||
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
|
||||
assert.equal(output.plaintext, `Free content\n\nMembers content`);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -233,25 +359,25 @@ describe('Post Email Serializer', function () {
|
|||
it('generates unsubscribe url for preview', function () {
|
||||
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
|
||||
const unsubscribeUrl = createUnsubscribeUrl(null);
|
||||
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?preview=1');
|
||||
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?preview=1');
|
||||
});
|
||||
|
||||
it('generates unsubscribe url with only member uuid', function () {
|
||||
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
|
||||
const unsubscribeUrl = createUnsubscribeUrl('member-abcd');
|
||||
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?uuid=member-abcd');
|
||||
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd');
|
||||
});
|
||||
|
||||
it('generates unsubscribe url with both post and newsletter uuid', function () {
|
||||
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
|
||||
const unsubscribeUrl = createUnsubscribeUrl('member-abcd', {newsletterUuid: 'newsletter-abcd'});
|
||||
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?uuid=member-abcd&newsletter=newsletter-abcd');
|
||||
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd&newsletter=newsletter-abcd');
|
||||
});
|
||||
|
||||
it('generates unsubscribe url with comments', function () {
|
||||
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
|
||||
const unsubscribeUrl = createUnsubscribeUrl('member-abcd', {comments: true});
|
||||
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?uuid=member-abcd&comments=1');
|
||||
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd&comments=1');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -270,7 +396,7 @@ describe('Post Email Serializer', function () {
|
|||
status: 'published',
|
||||
id: 'abc123'
|
||||
});
|
||||
unsubscribeUrl.should.eql('https://site.com/blah/#/portal/signup');
|
||||
assert.equal(unsubscribeUrl, 'https://site.com/blah/#/portal/signup');
|
||||
});
|
||||
|
||||
it('generates signup url on homepage for email only post', function () {
|
||||
|
@ -280,7 +406,7 @@ describe('Post Email Serializer', function () {
|
|||
status: 'sent',
|
||||
id: 'abc123'
|
||||
});
|
||||
unsubscribeUrl.should.eql('https://site.com/test/#/portal/signup');
|
||||
assert.equal(unsubscribeUrl, 'https://site.com/test/#/portal/signup');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -317,7 +443,7 @@ describe('Post Email Serializer', function () {
|
|||
}
|
||||
};
|
||||
const res = await _getTemplateSettings(newsletterMock);
|
||||
should(res).eql({
|
||||
assert.deepStrictEqual(res, {
|
||||
headerImage: 'image',
|
||||
showHeaderIcon: 'icon2',
|
||||
showHeaderTitle: true,
|
||||
|
|
|
@ -120,8 +120,6 @@ describe('Mega template', function () {
|
|||
});
|
||||
|
||||
it('Correctly escapes the contents', function () {
|
||||
// TODO: check html escaping based on mobiledoc instead of invalid html: https://github.com/TryGhost/Team/issues/1871
|
||||
|
||||
const post = {
|
||||
title: 'I <3 Posts',
|
||||
html: '<div class="post-content-html">I am <100 years old</div>',
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
"@tryghost/domain-events": "0.0.0",
|
||||
"@tryghost/errors": "1.2.15",
|
||||
"@tryghost/mongo-utils": "0.3.5",
|
||||
"@tryghost/string": "0.1.27",
|
||||
"@tryghost/string": "0.2.0",
|
||||
"lodash": "4.17.21"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
"uuid": "9.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/string": "0.1.27",
|
||||
"@tryghost/string": "0.2.0",
|
||||
"bcryptjs": "2.4.3"
|
||||
}
|
||||
}
|
||||
|
|
116
yarn.lock
116
yarn.lock
|
@ -659,7 +659,7 @@
|
|||
dependencies:
|
||||
"@babel/highlight" "^7.10.4"
|
||||
|
||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.18.6":
|
||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
|
||||
integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
|
||||
|
@ -2535,6 +2535,13 @@
|
|||
ember-compatibility-helpers "^1.2.1"
|
||||
ember-raf-scheduler "^0.3.0"
|
||||
|
||||
"@html-validate/stylish@^3.0.0":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@html-validate/stylish/-/stylish-3.0.1.tgz#9d0304c6fd91e4b19467b8b7d6f3f112fc7831ff"
|
||||
integrity sha512-jQNDrSnWvJEPSlqC1tFqcbmVuJy2x61UwqFsXHxYT2sgCXFW4AVhsoIcHkECCmUHtQ8hpHU6yOBGA+rMLZhS7A==
|
||||
dependencies:
|
||||
kleur "^4.0.0"
|
||||
|
||||
"@humanwhocodes/config-array@^0.10.4":
|
||||
version "0.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c"
|
||||
|
@ -3044,6 +3051,14 @@
|
|||
"@sentry/types" "7.8.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sidvind/better-ajv-errors@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@sidvind/better-ajv-errors/-/better-ajv-errors-2.0.0.tgz#88f822f04ea7314fc4e7fcc5e4b393640c74f6ab"
|
||||
integrity sha512-S+3eyeMbWQifgbDF15eojGVJi8n5uU0bGJYQsvsHPyU4lmrMsXvfiCh7MM0IPEF4a4jRBsljMExtKlo7kM0jqQ==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.16.0"
|
||||
chalk "^4.1.0"
|
||||
|
||||
"@simple-dom/document@^1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@simple-dom/document/-/document-1.4.0.tgz#af60855f957f284d436983798ef1006cca1a1678"
|
||||
|
@ -3364,12 +3379,12 @@
|
|||
dependencies:
|
||||
lodash-es "^4.17.11"
|
||||
|
||||
"@tryghost/html-to-mobiledoc@1.8.11":
|
||||
version "1.8.11"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/html-to-mobiledoc/-/html-to-mobiledoc-1.8.11.tgz#e4221f7db94d5b18ac15fece907d3024e124141b"
|
||||
integrity sha512-I8S8Y8rGCDpTSh0/3pPlS/8UepwEH5ORt83TZoYTx87Pt+evUPIvsq2XfppTkoclvv/vHsD5ntWD0Yt3Gwl4fg==
|
||||
"@tryghost/html-to-mobiledoc@1.8.12":
|
||||
version "1.8.12"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/html-to-mobiledoc/-/html-to-mobiledoc-1.8.12.tgz#762d8275dec94f5213a18afd178cb76d188870a2"
|
||||
integrity sha512-Zy78/VRCoT47LX7FCDM48JwrmV84uPeIUFRWw56+PDoSYoUO1QPE+38+iqPcfvs55GEQ5x+trhqKu0iYKh5MXg==
|
||||
dependencies:
|
||||
"@tryghost/kg-parser-plugins" "^2.12.0"
|
||||
"@tryghost/kg-parser-plugins" "^2.12.1"
|
||||
"@tryghost/mobiledoc-kit" "^0.12.4-ghost.1"
|
||||
jsdom "^18.0.0"
|
||||
|
||||
|
@ -3407,15 +3422,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@tryghost/kg-card-factory/-/kg-card-factory-3.1.5.tgz#c2358e7ed90ff71900a4da72e43aaa744aefdf35"
|
||||
integrity sha512-V68OpboKudFkx4ezKEqRVjZp7Q21EHC3FnpYT7RR+3deUqAm/Gu04s3mcsei0yTHPfuUEcHkjP7sEUZJyJSZeg==
|
||||
|
||||
"@tryghost/kg-clean-basic-html@2.2.14":
|
||||
version "2.2.14"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-clean-basic-html/-/kg-clean-basic-html-2.2.14.tgz#d8c2750e8832e3c1d316cfe7eb09243d34b70f1f"
|
||||
integrity sha512-qTbREH4aHTWpn9c7cTMCWN/exWybhkVnci62L+99oVO6CIHpLQfww+zL66YWhiqRU+TGGFQj7qHG93FquGtysg==
|
||||
|
||||
"@tryghost/kg-clean-basic-html@^2.2.15":
|
||||
version "2.2.15"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-clean-basic-html/-/kg-clean-basic-html-2.2.15.tgz#25d5ded0da6c6c9f3d5fbb3bf60579f08be65783"
|
||||
integrity sha512-p8pwBt6H8PWtfCQrG26atBybcCxzeMGOA/8suQDJ8ce5ieU57YwQHxLSTqa3/oGSfTLLuBBtSgD4G5NBnOOXxw==
|
||||
"@tryghost/kg-clean-basic-html@2.2.16", "@tryghost/kg-clean-basic-html@^2.2.16":
|
||||
version "2.2.16"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-clean-basic-html/-/kg-clean-basic-html-2.2.16.tgz#6610b9af1e97732f8da12de0e72ed5796f5d29f5"
|
||||
integrity sha512-2gC4J6gkiLGOd7SNl1BdSf/qE0uKyNNDvOyPFCZPKruW6DJ56PmBXceBlPm5q+V1PwAmLmzAYBGO5CfosKdbHg==
|
||||
|
||||
"@tryghost/kg-default-atoms@3.1.4":
|
||||
version "3.1.4"
|
||||
|
@ -3457,12 +3467,12 @@
|
|||
mobiledoc-dom-renderer "^0.7.0"
|
||||
simple-dom "^1.4.0"
|
||||
|
||||
"@tryghost/kg-parser-plugins@2.12.0", "@tryghost/kg-parser-plugins@^2.12.0":
|
||||
version "2.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-parser-plugins/-/kg-parser-plugins-2.12.0.tgz#74ad083d4fc3e1d1840848f257d01e2e25c8853c"
|
||||
integrity sha512-trRn612AWLlt9qly8nxDnrM5PHuDHmhogxof/G2oQzDkXQ7IUPyLBz/7V2yUCSlQ1VQVvhxoqqTbUnPYzSUVAw==
|
||||
"@tryghost/kg-parser-plugins@2.12.1", "@tryghost/kg-parser-plugins@^2.12.1":
|
||||
version "2.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-parser-plugins/-/kg-parser-plugins-2.12.1.tgz#91fc233a72990493b9377b82561b37416314028b"
|
||||
integrity sha512-mZmZraOU/uICmeeAAlWffbGtlkgtBW7fvB90rPJ+V5kv1ojRxa38cy0a/EUrjiir2dUCVk2zJFdTzHuf4UevEg==
|
||||
dependencies:
|
||||
"@tryghost/kg-clean-basic-html" "^2.2.15"
|
||||
"@tryghost/kg-clean-basic-html" "^2.2.16"
|
||||
|
||||
"@tryghost/kg-utils@^1.0.4":
|
||||
version "1.0.4"
|
||||
|
@ -3639,14 +3649,7 @@
|
|||
dependencies:
|
||||
moment "^2.29.3"
|
||||
|
||||
"@tryghost/string@0.1.27":
|
||||
version "0.1.27"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.1.27.tgz#c8f9b9748e2a7bea02953763865e0820223a2918"
|
||||
integrity sha512-M3vUxiV4atX10aV45VOgs8DW9X29z5gd/KhfSP3AzKeu2rEiLx3nWC4lD7xTMZocZED1QNwvGXbjMEwM9aNGmw==
|
||||
dependencies:
|
||||
unidecode "^0.1.8"
|
||||
|
||||
"@tryghost/string@^0.2.0":
|
||||
"@tryghost/string@0.2.0", "@tryghost/string@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.2.0.tgz#e39ca183cf5fe4522ab3f698de7d1c0affb03082"
|
||||
integrity sha512-JWWXoUWaJPI/fY7+WHrfInt2S0IV1ZN+km98AIw4GwuWoXIMbUoKp1K/JKoVQ4XrJXwvNMnmxNKFChF1kpdcGQ==
|
||||
|
@ -4446,6 +4449,11 @@ acorn-walk@^7.1.1:
|
|||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
|
||||
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
|
||||
|
||||
acorn-walk@^8.0.0:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
|
||||
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
|
||||
|
||||
acorn@^5.0.0, acorn@^5.5.3:
|
||||
version "5.7.4"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
|
||||
|
@ -8704,7 +8712,7 @@ deep-is@^0.1.3, deep-is@~0.1.3:
|
|||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
||||
|
||||
deepmerge@^4.0.0, deepmerge@^4.2.2:
|
||||
deepmerge@^4.0.0, deepmerge@^4.2.0, deepmerge@^4.2.2:
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
|
||||
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
|
||||
|
@ -11164,7 +11172,7 @@ espree@^7.3.0, espree@^7.3.1:
|
|||
acorn-jsx "^5.3.1"
|
||||
eslint-visitor-keys "^1.3.0"
|
||||
|
||||
espree@^9.4.0:
|
||||
espree@^9.0.0, espree@^9.4.0:
|
||||
version "9.4.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a"
|
||||
integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==
|
||||
|
@ -12496,7 +12504,7 @@ glob@7.2.0:
|
|||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
glob@8.0.3, glob@^8.0.1, glob@^8.0.3:
|
||||
glob@8.0.3, glob@^8.0.0, glob@^8.0.1, glob@^8.0.3:
|
||||
version "8.0.3"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
|
||||
integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==
|
||||
|
@ -13112,6 +13120,25 @@ html-to-text@8.2.1:
|
|||
minimist "^1.2.6"
|
||||
selderee "^0.6.0"
|
||||
|
||||
html-validate@^7.3.3:
|
||||
version "7.3.3"
|
||||
resolved "https://registry.yarnpkg.com/html-validate/-/html-validate-7.3.3.tgz#9186aad1bbf894b6ec506a1b5be33a45bda89dc6"
|
||||
integrity sha512-nABzmBHUb+TbiqLb3ggoC7uEN5mKgZSwbB/LpGyXbrI+ajVBnyYyRMscLFOZGYpw5gacBZtqkK6VzFU7jbXObg==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.10.0"
|
||||
"@html-validate/stylish" "^3.0.0"
|
||||
"@sidvind/better-ajv-errors" "^2.0.0"
|
||||
acorn-walk "^8.0.0"
|
||||
ajv "^8.0.0"
|
||||
deepmerge "^4.2.0"
|
||||
espree "^9.0.0"
|
||||
glob "^8.0.0"
|
||||
ignore "^5.0.0"
|
||||
kleur "^4.1.0"
|
||||
minimist "^1.2.0"
|
||||
prompts "^2.0.0"
|
||||
semver "^7.0.0"
|
||||
|
||||
htmlparser2@^3.10.1, htmlparser2@^3.9.1:
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
|
||||
|
@ -13343,7 +13370,7 @@ ignore@^4.0.6:
|
|||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
|
||||
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
|
||||
|
||||
ignore@^5.1.1, ignore@^5.2.0:
|
||||
ignore@^5.0.0, ignore@^5.1.1, ignore@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
|
||||
integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
|
||||
|
@ -14939,6 +14966,16 @@ kind-of@^6.0.0, kind-of@^6.0.2:
|
|||
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
|
||||
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
|
||||
|
||||
kleur@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
||||
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
|
||||
|
||||
kleur@^4.0.0, kleur@^4.1.0:
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
|
||||
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
|
||||
|
||||
knex-migrator@5.0.4:
|
||||
version "5.0.4"
|
||||
resolved "https://registry.yarnpkg.com/knex-migrator/-/knex-migrator-5.0.4.tgz#1e2a0530bffdb432c4d9c29a1fde9171d82a3565"
|
||||
|
@ -19096,6 +19133,14 @@ promise.hash.helper@^1.0.7:
|
|||
resolved "https://registry.yarnpkg.com/promise.hash.helper/-/promise.hash.helper-1.0.8.tgz#8c5fa0570f6f96821f52364fd72292b2c5a114f7"
|
||||
integrity sha512-KYcnXctWUWyVD3W3Ye0ZDuA1N8Szrh85cVCxpG6xYrOk/0CttRtYCmU30nWsUch0NuExQQ63QXvzRE6FLimZmg==
|
||||
|
||||
prompts@^2.0.0:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"
|
||||
integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==
|
||||
dependencies:
|
||||
kleur "^3.0.3"
|
||||
sisteransi "^1.0.5"
|
||||
|
||||
prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
|
@ -20301,7 +20346,7 @@ semver@7.0.0, semver@~7.0.0:
|
|||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
|
||||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
||||
semver@7.3.7, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
|
||||
semver@7.3.7, semver@^7.0.0, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
|
||||
version "7.3.7"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
|
||||
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
|
||||
|
@ -20589,6 +20634,11 @@ sinon@^9.0.0:
|
|||
nise "^4.0.4"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
sisteransi@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
|
||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
||||
|
||||
slash@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
|
||||
|
|
Loading…
Add table
Reference in a new issue