0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Added paywall card to mark end of free content preview (#12663)

closes https://github.com/TryGhost/Team/issues/466

- upgraded kg-default-cards to include paywall card
- extracted `htmlToPlaintext` from post model to shared util for re-use
- updated post-gating to set html+plaintext to the free preview if a paywall card has been used
  - re-generates plaintext from the truncated html using `htmlToPlaintext` util
- display free content in the `{{content}}` helper via the default CTA template
This commit is contained in:
Kevin Ansfield 2021-02-17 23:00:26 +00:00 committed by GitHub
parent abb8c1df74
commit 2c96df42ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 179 additions and 48 deletions

View file

@ -1,3 +1,4 @@
{{{html}}}
<aside class="gh-post-upgrade-cta">
<div class="gh-post-upgrade-cta-content" style="background-color: {{accentColor}}">
{{#has visibility="paid"}}

View file

@ -1,4 +1,5 @@
const membersService = require('../../../../../../services/members');
const htmlToPlaintext = require('../../../../../../../shared/html-to-plaintext');
// @TODO: reconsider the location of this - it's part of members and adds a property to the API
const forPost = (attrs, frame) => {
@ -10,11 +11,18 @@ const forPost = (attrs, frame) => {
const memberHasAccess = membersService.contentGating.checkPostAccess(attrs, frame.original.context.member);
if (!memberHasAccess) {
['plaintext', 'html'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
const paywallIndex = (attrs.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1) {
attrs.html = attrs.html.slice(0, paywallIndex);
attrs.plaintext = htmlToPlaintext(attrs.html);
} else {
['plaintext', 'html'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
}
}
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') || (frame.options.columns.includes('access'))) {

View file

@ -1,14 +1,22 @@
const membersService = require('../../../../../../services/members');
const htmlToPlaintext = require('../../../../../../../shared/html-to-plaintext');
const forPost = (attrs, frame) => {
const memberHasAccess = membersService.contentGating.checkPostAccess(attrs, frame.original.context.member);
if (!memberHasAccess) {
['plaintext', 'html'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
const paywallIndex = (attrs.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1) {
attrs.html = attrs.html.slice(0, paywallIndex);
attrs.plaintext = htmlToPlaintext(attrs.html);
} else {
['plaintext', 'html'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
}
}
return attrs;

View file

@ -1,4 +1,5 @@
const membersService = require('../../../../../../services/members');
const htmlToPlaintext = require('../../../../../../../shared/html-to-plaintext');
// @TODO: reconsider the location of this - it's part of members and adds a property to the API
const forPost = (attrs, frame) => {
@ -10,11 +11,18 @@ const forPost = (attrs, frame) => {
const memberHasAccess = membersService.contentGating.checkPostAccess(attrs, frame.original.context.member);
if (!memberHasAccess) {
['plaintext', 'html'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
const paywallIndex = (attrs.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1) {
attrs.html = attrs.html.slice(0, paywallIndex);
attrs.plaintext = htmlToPlaintext(attrs.html);
} else {
['plaintext', 'html'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
}
}
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') || (frame.options.columns.includes('access'))) {

View file

@ -6,7 +6,7 @@ const Promise = require('bluebird');
const {sequence} = require('@tryghost/promise');
const {i18n} = require('../lib/common');
const errors = require('@tryghost/errors');
const htmlToText = require('html-to-text');
const htmlToPlaintext = require('../../shared/html-to-plaintext');
const ghostBookshelf = require('./base');
const config = require('../../shared/config');
const settingsCache = require('../services/settings/cache');
@ -481,14 +481,7 @@ Post = ghostBookshelf.Model.extend({
if (this.get('html') === null) {
plaintext = null;
} else {
plaintext = htmlToText.fromString(this.get('html'), {
wordwrap: 80,
ignoreImage: true,
hideLinkHrefIfSameAsText: true,
preserveNewlines: true,
returnDomByDefault: true,
uppercaseHeadings: false
});
plaintext = htmlToPlaintext(this.get('html'));
}
// CASE: html is e.g. <p></p>

View file

@ -0,0 +1,12 @@
const htmlToText = require('html-to-text');
module.exports = function htmlToPlaintext(html) {
return htmlToText.fromString(html, {
wordwrap: 80,
ignoreImage: true,
hideLinkHrefIfSameAsText: true,
preserveNewlines: true,
returnDomByDefault: true,
uppercaseHeadings: false
});
};

View file

@ -52,7 +52,7 @@
"@tryghost/job-manager": "0.7.2",
"@tryghost/kg-card-factory": "2.1.6",
"@tryghost/kg-default-atoms": "2.0.3",
"@tryghost/kg-default-cards": "4.0.0-rc.3",
"@tryghost/kg-default-cards": "4.0.0-rc.5",
"@tryghost/kg-markdown-html-renderer": "4.0.0-rc.1",
"@tryghost/kg-mobiledoc-html-renderer": "4.0.0-rc.1",
"@tryghost/magic-link": "0.6.6",

View file

@ -257,6 +257,7 @@ describe('api/canary/content/posts', function () {
let publicPost;
let membersPost;
let paidPost;
let membersPostWithPaywallCard;
before(function () {
// NOTE: ideally this would be set through Admin API request not a stub
@ -282,10 +283,19 @@ describe('api/canary/content/posts', function () {
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
});
membersPostWithPaywallCard = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-shalt-have-a-taste',
visibility: 'members',
mobiledoc: '{"version":"0.3.1","markups":[],"atoms":[],"cards":[["paywall",{}]],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members content"]]]]}',
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
published_at: moment().add(5, 'seconds').toDate()
});
return testUtils.fixtures.insertPosts([
publicPost,
membersPost,
paidPost
paidPost,
membersPostWithPaywallCard
]);
});
@ -362,6 +372,24 @@ describe('api/canary/content/posts', function () {
});
});
it('can read "free" html and plaintext content of members post when using paywall card', function () {
return request
.get(localUtils.API.getApiQuery(`posts/${membersPostWithPaywallCard.id}/?key=${validKey}&formats=html,plaintext&fields=html,plaintext`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
const post = jsonResponse.posts[0];
localUtils.API.checkResponse(post, 'post', null, null, ['id', 'html', 'plaintext']);
post.html.should.eql('<p>Free content</p>');
post.plaintext.should.eql('Free content');
});
});
it('cannot browse members only posts content', function () {
return request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}`))
.set('Origin', testUtils.API.getURL())
@ -376,7 +404,7 @@ describe('api/canary/content/posts', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
localUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(14);
jsonResponse.posts.should.have.length(15);
localUtils.API.checkResponse(jsonResponse.posts[0], 'post', null, null);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
@ -385,18 +413,20 @@ describe('api/canary/content/posts', function () {
jsonResponse.posts[0].slug.should.eql('thou-shalt-not-be-seen');
jsonResponse.posts[1].slug.should.eql('thou-shalt-be-paid-for');
jsonResponse.posts[2].slug.should.eql('free-to-see');
jsonResponse.posts[7].slug.should.eql('organising-content');
jsonResponse.posts[3].slug.should.eql('thou-shalt-have-a-taste');
jsonResponse.posts[8].slug.should.eql('organising-content');
jsonResponse.posts[0].html.should.eql('');
jsonResponse.posts[1].html.should.eql('');
jsonResponse.posts[2].html.should.not.eql('');
jsonResponse.posts[7].html.should.not.eql('');
jsonResponse.posts[3].html.should.not.eql('');
jsonResponse.posts[8].html.should.not.eql('');
// check meta response for this test
jsonResponse.meta.pagination.page.should.eql(1);
jsonResponse.meta.pagination.limit.should.eql(15);
jsonResponse.meta.pagination.pages.should.eql(1);
jsonResponse.meta.pagination.total.should.eql(14);
jsonResponse.meta.pagination.total.should.eql(15);
jsonResponse.meta.pagination.hasOwnProperty('next').should.be.true();
jsonResponse.meta.pagination.hasOwnProperty('prev').should.be.true();
should.not.exist(jsonResponse.meta.pagination.next);

View file

@ -207,6 +207,7 @@ describe('api/v2/content/posts', function () {
let publicPost;
let membersPost;
let paidPost;
let membersPostWithPaywallCard;
before(function () {
// NOTE: ideally this would be set through Admin API request not a stub
@ -232,10 +233,19 @@ describe('api/v2/content/posts', function () {
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
});
membersPostWithPaywallCard = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-shalt-have-a-taste',
visibility: 'members',
mobiledoc: '{"version":"0.3.1","markups":[],"atoms":[],"cards":[["paywall",{}]],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members content"]]]]}',
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
published_at: moment().add(5, 'seconds').toDate()
});
return testUtils.fixtures.insertPosts([
publicPost,
membersPost,
paidPost
paidPost,
membersPostWithPaywallCard
]);
});
@ -312,6 +322,24 @@ describe('api/v2/content/posts', function () {
});
});
it('can read "free" html and plaintext content of members post when using paywall card', function () {
return request
.get(localUtils.API.getApiQuery(`posts/${membersPostWithPaywallCard.id}/?key=${validKey}&formats=html,plaintext&fields=html,plaintext`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
const post = jsonResponse.posts[0];
localUtils.API.checkResponse(post, 'post', null, null, ['id', 'html', 'plaintext']);
post.html.should.eql('<p>Free content</p>');
post.plaintext.should.eql('Free content');
});
});
it('cannot browse members only posts content', function () {
return request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}`))
.set('Origin', testUtils.API.getURL())
@ -326,7 +354,7 @@ describe('api/v2/content/posts', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
localUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(14);
jsonResponse.posts.should.have.length(15);
localUtils.API.checkResponse(jsonResponse.posts[0], 'post');
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
@ -335,18 +363,20 @@ describe('api/v2/content/posts', function () {
jsonResponse.posts[0].slug.should.eql('thou-shalt-not-be-seen');
jsonResponse.posts[1].slug.should.eql('thou-shalt-be-paid-for');
jsonResponse.posts[2].slug.should.eql('free-to-see');
jsonResponse.posts[7].slug.should.eql('organising-content');
jsonResponse.posts[3].slug.should.eql('thou-shalt-have-a-taste');
jsonResponse.posts[8].slug.should.eql('organising-content');
jsonResponse.posts[0].html.should.eql('');
jsonResponse.posts[1].html.should.eql('');
jsonResponse.posts[2].html.should.not.eql('');
jsonResponse.posts[7].html.should.not.eql('');
jsonResponse.posts[3].html.should.not.eql('');
jsonResponse.posts[8].html.should.not.eql('');
// check meta response for this test
jsonResponse.meta.pagination.page.should.eql(1);
jsonResponse.meta.pagination.limit.should.eql(15);
jsonResponse.meta.pagination.pages.should.eql(1);
jsonResponse.meta.pagination.total.should.eql(14);
jsonResponse.meta.pagination.total.should.eql(15);
jsonResponse.meta.pagination.hasOwnProperty('next').should.be.true();
jsonResponse.meta.pagination.hasOwnProperty('prev').should.be.true();
should.not.exist(jsonResponse.meta.pagination.next);

View file

@ -257,6 +257,7 @@ describe('api/v3/content/posts', function () {
let publicPost;
let membersPost;
let paidPost;
let membersPostWithPaywallCard;
before(function () {
// NOTE: ideally this would be set through Admin API request not a stub
@ -282,10 +283,19 @@ describe('api/v3/content/posts', function () {
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
});
membersPostWithPaywallCard = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-shalt-have-a-taste',
visibility: 'members',
mobiledoc: '{"version":"0.3.1","markups":[],"atoms":[],"cards":[["paywall",{}]],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members content"]]]]}',
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
published_at: moment().add(5, 'seconds').toDate()
});
return testUtils.fixtures.insertPosts([
publicPost,
membersPost,
paidPost
paidPost,
membersPostWithPaywallCard
]);
});
@ -362,6 +372,24 @@ describe('api/v3/content/posts', function () {
});
});
it('can read "free" html and plaintext content of members post when using paywall card', function () {
return request
.get(localUtils.API.getApiQuery(`posts/${membersPostWithPaywallCard.id}/?key=${validKey}&formats=html,plaintext&fields=html,plaintext`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
const post = jsonResponse.posts[0];
localUtils.API.checkResponse(post, 'post', null, null, ['id', 'html', 'plaintext']);
post.html.should.eql('<p>Free content</p>');
post.plaintext.should.eql('Free content');
});
});
it('cannot browse members only posts content', function () {
return request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}`))
.set('Origin', testUtils.API.getURL())
@ -376,7 +404,7 @@ describe('api/v3/content/posts', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
localUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(14);
jsonResponse.posts.should.have.length(15);
localUtils.API.checkResponse(jsonResponse.posts[0], 'post', null, null);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
@ -385,18 +413,19 @@ describe('api/v3/content/posts', function () {
jsonResponse.posts[0].slug.should.eql('thou-shalt-not-be-seen');
jsonResponse.posts[1].slug.should.eql('thou-shalt-be-paid-for');
jsonResponse.posts[2].slug.should.eql('free-to-see');
jsonResponse.posts[7].slug.should.eql('organising-content');
jsonResponse.posts[3].slug.should.eql('thou-shalt-have-a-taste');
jsonResponse.posts[8].slug.should.eql('organising-content');
jsonResponse.posts[0].html.should.eql('');
jsonResponse.posts[1].html.should.eql('');
jsonResponse.posts[2].html.should.not.eql('');
jsonResponse.posts[7].html.should.not.eql('');
jsonResponse.posts[8].html.should.not.eql('');
// check meta response for this test
jsonResponse.meta.pagination.page.should.eql(1);
jsonResponse.meta.pagination.limit.should.eql(15);
jsonResponse.meta.pagination.pages.should.eql(1);
jsonResponse.meta.pagination.total.should.eql(14);
jsonResponse.meta.pagination.total.should.eql(15);
jsonResponse.meta.pagination.hasOwnProperty('next').should.be.true();
jsonResponse.meta.pagination.hasOwnProperty('prev').should.be.true();
should.not.exist(jsonResponse.meta.pagination.next);

View file

@ -99,7 +99,7 @@ describe('{{content}} helper with no access', function () {
});
it('can render default template', function () {
const html = 'Hello World';
const html = '';
const rendered = helpers.content.call({html: html, access: false}, optionsData);
rendered.string.should.containEql('gh-post-upgrade-cta');
rendered.string.should.containEql('gh-post-upgrade-cta-content');
@ -107,9 +107,19 @@ describe('{{content}} helper with no access', function () {
should.exist(rendered);
});
it('outputs free content if available via paywall card', function () {
// html will be included when there is free content available
const html = 'Free content';
const rendered = helpers.content.call({html: html, access: false}, optionsData);
rendered.string.should.containEql('Free content');
rendered.string.should.containEql('gh-post-upgrade-cta');
rendered.string.should.containEql('gh-post-upgrade-cta-content');
rendered.string.should.containEql('"background-color: #abcdef"');
});
});
describe('{{content}} helper with no access', function () {
describe('{{content}} helper with custom template', function () {
let optionsData;
before(function (done) {
hbs.express4({partialsDir: [path.resolve(__dirname, './test_tpl')]});

View file

@ -30,6 +30,7 @@ describe('lib/mobiledoc', function () {
['markdown', {
markdown: '# Markdown card\nSome markdown'
}],
['paywall', {}],
['hr', {}],
['image', {
cardWidth: 'wide',
@ -67,18 +68,19 @@ describe('lib/mobiledoc', function () {
]],
[10, 1],
[10, 2],
[10, 3],
[1, 'p', [
[0, [], 0, 'Four']
]],
[10, 3],
[10, 4],
[10, 5],
[10, 6],
[1, 'p', []]
]
};
mobiledocLib.mobiledocHtmlRenderer.render(mobiledoc)
.should.eql('<p>One<br>Two</p><!--kg-card-begin: markdown--><h1 id="markdowncard">Markdown card</h1>\n<p>Some markdown</p>\n<!--kg-card-end: markdown--><p>Three</p><hr><figure class="kg-card kg-image-card kg-width-wide kg-card-hascaption"><img src="/content/images/2018/04/NatGeo06.jpg" class="kg-image" alt width="2000" height="1000" srcset="/content/images/size/w600/2018/04/NatGeo06.jpg 600w, /content/images/size/w1000/2018/04/NatGeo06.jpg 1000w, /content/images/size/w1600/2018/04/NatGeo06.jpg 1600w, /content/images/size/w2400/2018/04/NatGeo06.jpg 2400w" sizes="(min-width: 1200px) 1200px"><figcaption>Birdies</figcaption></figure><p>Four</p><!--kg-card-begin: html--><h2>HTML card</h2>\n<div><p>Some HTML</p></div><!--kg-card-end: html--><figure class="kg-card kg-embed-card"><h2>Embed card</h2></figure><figure class="kg-card kg-gallery-card kg-width-wide"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="/content/images/test.png" width="1000" height="500" alt srcset="/content/images/size/w600/test.png 600w, /content/images/test.png 1000w" sizes="(min-width: 720px) 720px"></div></div></div></figure>');
.should.eql('<p>One<br>Two</p><!--kg-card-begin: markdown--><h1 id="markdowncard">Markdown card</h1>\n<p>Some markdown</p>\n<!--kg-card-end: markdown--><p>Three</p><!--members-only--><hr><figure class="kg-card kg-image-card kg-width-wide kg-card-hascaption"><img src="/content/images/2018/04/NatGeo06.jpg" class="kg-image" alt width="2000" height="1000" srcset="/content/images/size/w600/2018/04/NatGeo06.jpg 600w, /content/images/size/w1000/2018/04/NatGeo06.jpg 1000w, /content/images/size/w1600/2018/04/NatGeo06.jpg 1600w, /content/images/size/w2400/2018/04/NatGeo06.jpg 2400w" sizes="(min-width: 1200px) 1200px"><figcaption>Birdies</figcaption></figure><p>Four</p><!--kg-card-begin: html--><h2>HTML card</h2>\n<div><p>Some HTML</p></div><!--kg-card-end: html--><figure class="kg-card kg-embed-card"><h2>Embed card</h2></figure><figure class="kg-card kg-gallery-card kg-width-wide"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="/content/images/test.png" width="1000" height="500" alt srcset="/content/images/size/w600/test.png 600w, /content/images/test.png 1000w" sizes="(min-width: 720px) 720px"></div></div></div></figure>');
});
it('renders according to ghostVersion', function () {

View file

@ -423,10 +423,10 @@
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-atoms/-/kg-default-atoms-2.0.3.tgz#b4a7a4c502a7b9940854cbcf7868b0a4f23b9edd"
integrity sha512-ZC3Lk7X0fGB+nPBSVF3PeirYuEX9sjNd5awmr5X//q8B5UdtUdKqzkW7DvYyABmI0/iL7HkUeZvETx22b3V7bw==
"@tryghost/kg-default-cards@4.0.0-rc.3":
version "4.0.0-rc.3"
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-cards/-/kg-default-cards-4.0.0-rc.3.tgz#5f49a0806c14cde18b37b0c204b6d02c4a1058ce"
integrity sha512-/8lm8BjI56Jgs3nGdK58ap8QZUP+ZLnc+1fmGKN7oCyq5Jdq2NWznscEHrN22jy/T+cFUJvUKiUdTIee4Q5Qhw==
"@tryghost/kg-default-cards@4.0.0-rc.5":
version "4.0.0-rc.5"
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-cards/-/kg-default-cards-4.0.0-rc.5.tgz#96b402b109abe85810c9caa3b5610bc5cc426808"
integrity sha512-7k/qp/QYjQjFbzCR8a9CnCoYY2FFaAEhktZsPSxPDyLy8laVD3nAatcuGA2zfvFmUErZZfg0CWsLVl7Mzsh1CQ==
dependencies:
"@tryghost/kg-markdown-html-renderer" "^3.0.1-4.0.0-rc.0.0"
"@tryghost/url-utils" "^0.6.14"