diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index 7e89026a6d..7280bcd5a0 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -367,6 +367,47 @@ Post = ghostBookshelf.Model.extend({ ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options); }, + onFetched: async function onFetched(model, response, options) { + if (!labs.isSet('collectionsCard')) { + return; + } + + await this.renderIfNeeded(model, options); + }, + + onFetchedCollection: async function onFetched(collection, response, options) { + if (!labs.isSet('collectionsCard')) { + return; + } + + for await (const model of collection.models) { + await this.renderIfNeeded(model, options); + } + }, + + renderIfNeeded: async function renderIfNeeded(model, options = {}) { + // pages can have their html cleared to "queue" a re-render to update dynamic data such + // as collection cards. Detect that and re-render here so the page is always up to date + if (model.get('lexical') !== null && model.get('html') === null) { + const html = await lexicalLib.render(model.get('lexical')); + const plaintext = htmlToPlaintext.excerpt(html); + + // set model attributes so they are available immediately in code that uses the returned model + model.set('html', html); + model.set('plaintext', plaintext); + + // update database manually using knex to avoid hooks being called multiple times + const query = ghostBookshelf.knex.raw('UPDATE posts SET html = ?, plaintext = ? WHERE id = ?', [html, plaintext, model.id]); + if (options.transacting) { + await query.transacting(options.transacting); + } else { + await query; + } + } + + return model; + }, + /** * We update the tags after the Post was inserted. * We update the tags before the Post was updated, see `onSaving` event. @@ -464,9 +505,25 @@ Post = ghostBookshelf.Model.extend({ } }, - onDestroyed: function onDestroyed(model, options) { + onDestroyed: async function onDestroyed(model, options) { ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments); + if (labs.isSet('collectionsCard') && model.previous('type') === 'post' && model.previous('status') === 'published') { + // reset all page HTML when a published post is deleted so they can be re-rendered + // on next fetch so any collection cards are "dynamically" updated + const resetPages = function resetPages(transacting) { + return ghostBookshelf.knex.raw('UPDATE posts set html = NULL WHERE type = \'page\' AND lexical IS NOT NULL').transacting(transacting); + }; + + if (options.transacting) { + await resetPages(options.transacting); + } else { + await ghostBookshelf.knex.transaction(async (transacting) => { + await resetPages(transacting); + }); + } + } + if (model.previous('status') === 'published') { model.emitChange('unpublished', Object.assign({usePreviousAttribute: true}, options)); } @@ -941,6 +998,19 @@ Post = ghostBookshelf.Model.extend({ }))); } + if (labs.isSet('collectionsCard') && this.get('type') === 'post' && (newStatus === 'published' || olderStatus === 'published')) { + // reset all page HTML when a published post is updated so they can be re-rendered + // on next fetch so any collection cards are "dynamically" updated + ops.push(async function resetPageHTML() { + const query = ghostBookshelf.knex.raw('UPDATE posts set html = NULL WHERE type = ? AND lexical IS NOT NULL', ['page']); + if (options.transacting) { + await query.transacting(options.transacting); + } else { + await query; + } + }); + } + return sequence(ops); }, diff --git a/ghost/core/core/server/models/relations/authors.js b/ghost/core/core/server/models/relations/authors.js index 49fdcf5785..733ab64fb3 100644 --- a/ghost/core/core/server/models/relations/authors.js +++ b/ghost/core/core/server/models/relations/authors.js @@ -71,7 +71,7 @@ module.exports.extendModel = function extendModel(Post, Posts, ghostBookshelf) { model._originalOptions = collection._originalOptions; })); - return proto.onFetchingCollection.call(this, collection, attrs, options); + return proto.onFetchedCollection.call(this, collection, attrs, options); }, onCreating: function onCreating(model, attrs, options) { diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap index 165bfe0f38..15990bc99c 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap @@ -1,5 +1,562 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Pages API Browse Re-renders html when null 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 15, + "next": null, + "page": 1, + "pages": 1, + "prev": null, + "total": 6, + }, + }, + "pages": Array [ + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "Static page test is what this is for. + +Hopefully you don't find it a bore.", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Static page test is what this is for.

Hopefully you don't find it a bore.

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"markdown\\",\\"markdown\\":\\"

Static page test is what this is for.

Hopefully you don't find it a bore.

\\"}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "static-page-draft", + "status": "draft", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "This is a draft static page", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "Oh hey, you clicked every link of our starter content and even clicked this small link in the footer! If you like Ghost and you're enjoying the product so far, we'd hugely appreciate your support in any way you care to show it. + +Ghost is a non-profit organization, and we give away all our intellectual property as open source software. If you believe in what we do, there are a number of ways you can give us a hand, and we hugely appreciate all of them: + + * Contribute code via GitHub + * Contribute", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Oh hey, you clicked every link of our starter content and even clicked this small link in the footer! If you like Ghost and you're enjoying the product so far, we'd hugely appreciate your support in any way you care to show it.

Ghost is a non-profit organization, and we give away all our intellectual property as open source software. If you believe in what we do, there are a number of ways you can give us a hand, and we hugely appreciate all of them:

  • Contribute code via GitHub
  • Contribute financially via GitHub Sponsors
  • Contribute financially via Open Collective
  • Contribute reviews via writing a blog post
  • Contribute good vibes via telling your friends about us

Thanks for checking us out!

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Oh hey, you clicked every link of our starter content and even clicked this small link in the footer! If you like Ghost and you're enjoying the product so far, we'd hugely appreciate your support in any way you care to show it.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Ghost is a non-profit organization, and we give away all our intellectual property as open source software. If you believe in what we do, there are a number of ways you can give us a hand, and we hugely appreciate all of them:\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1},{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Contribute code via \\",\\"type\\":\\"text\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"GitHub\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"link\\",\\"rel\\":null,\\"target\\":null,\\"title\\":null,\\"url\\":\\"https://github.com/tryghost\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":1,\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Contribute financially via \\",\\"type\\":\\"text\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"GitHub Sponsors\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"link\\",\\"rel\\":null,\\"target\\":null,\\"title\\":null,\\"url\\":\\"https://github.com/sponsors/TryGhost\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":2,\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Contribute financially via \\",\\"type\\":\\"text\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Open Collective\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"link\\",\\"rel\\":null,\\"target\\":null,\\"title\\":null,\\"url\\":\\"https://opencollective.com/ghost\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":3,\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Contribute reviews via \\",\\"type\\":\\"text\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":1,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"writing a blog post\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":4,\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Contribute good vibes via \\",\\"type\\":\\"text\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":1,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"telling your friends\\",\\"type\\":\\"text\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\" about us\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":5,\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"tag\\":\\"ul\\",\\"type\\":\\"list\\",\\"listType\\":\\"bullet\\",\\"start\\":1,\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Thanks for checking us out!\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "contribute", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "Contribute", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind. + +You can integrate any products, services, ads or integrations with Ghost yourself if you want to, but it's always a good idea to disclose how subscriber data will be used by putting together a privacy page.", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind.

You can integrate any products, services, ads or integrations with Ghost yourself if you want to, but it's always a good idea to disclose how subscriber data will be used by putting together a privacy page.

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"You can integrate any products, services, ads or integrations with Ghost yourself if you want to, but it's always a good idea to disclose how subscriber data will be used by putting together a privacy page.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "privacy", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "Privacy", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "If you want to set up a contact page for people to be able to reach out to you, the simplest way is to set up a simple page like this and list the different ways people can reach out to you. + + +For example, here's how to reach us! + + * @Ghost on Twitter + * @Ghost on Facebook + * @Ghost on Instagram + +If you prefer to use a contact form, almost all of the great embedded form services work great with Ghost and are easy to set up:", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

If you want to set up a contact page for people to be able to reach out to you, the simplest way is to set up a simple page like this and list the different ways people can reach out to you.

For example, here's how to reach us!

If you prefer to use a contact form, almost all of the great embedded form services work great with Ghost and are easy to set up:

\\"\\"
", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"If you want to set up a contact page for people to be able to reach out to you, the simplest way is to set up a simple page like this and list the different ways people can reach out to you.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"For example, here's how to reach us!\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"heading\\",\\"tag\\":\\"h3\\",\\"version\\":1},{\\"children\\":[{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"@Ghost\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"link\\",\\"rel\\":null,\\"target\\":null,\\"title\\":null,\\"url\\":\\"https://twitter.com/ghost\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\" on Twitter\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":1,\\"version\\":1},{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"@Ghost\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"link\\",\\"rel\\":null,\\"target\\":null,\\"title\\":null,\\"url\\":\\"https://www.facebook.com/ghost\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\" on Facebook\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":2,\\"version\\":1},{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"@Ghost\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"link\\",\\"rel\\":null,\\"target\\":null,\\"title\\":null,\\"url\\":\\"https://instagram.com/ghost\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\" on Instagram\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"listitem\\",\\"value\\":3,\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"tag\\":\\"ul\\",\\"type\\":\\"list\\",\\"listType\\":\\"bullet\\",\\"start\\":1,\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"If you prefer to use a contact form, almost all of the great embedded form services work great with Ghost and are easy to set up:\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1},{\\"type\\":\\"image\\",\\"src\\":\\"https://static.ghost.org/v4.0.0/images/integrations.png\\",\\"width\\":2944,\\"height\\":1716,\\"href\\":\\"https://ghost.org/integrations/?tag=forms\\"},{\\"children\\":[],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 1, + "show_title_and_feature_image": Any, + "slug": "contact", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "Contact", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "Unlike posts, pages in Ghost don't appear in the main feed. They're separate, individual pages which only show up when you link to them. Great for content which is important, but separate from your usual posts. + +An about page is a great example of one you might want to set up early on so people can find out more about you, and what you do. Why should people subscribe to your site and become a member? Details help! + +Tip: If you're reading any post or page on your site and you notice something you", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Unlike posts, pages in Ghost don't appear in the main feed. They're separate, individual pages which only show up when you link to them. Great for content which is important, but separate from your usual posts.

An about page is a great example of one you might want to set up early on so people can find out more about you, and what you do. Why should people subscribe to your site and become a member? Details help!

Tip: If you're reading any post or page on your site and you notice something you want to edit, you can add /edit to the end of the URL – and you'll be taken directly to the Ghost editor.

Now tell the world what your site is all about.

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Unlike posts, pages in Ghost don't appear in the main feed. They're separate, individual pages which only show up when you link to them. Great for content which is important, but separate from your usual posts.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"An about page is a great example of one you might want to set up early on so people can find out more about you, and what you do. Why should people subscribe to your site and become a member? Details help!\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":1,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Tip: \\",\\"type\\":\\"text\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"If you're reading any post or page on your site and you notice something you want to edit, you can add \\",\\"type\\":\\"text\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":16,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"/edit\\",\\"type\\":\\"text\\",\\"version\\":1},{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\" to the end of the URL – and you'll be taken directly to the Ghost editor.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"quote\\",\\"version\\":1},{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Now tell the world what your site is all about.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "about", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "About this site", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "Static page test is what this is for. + +Hopefully you don't find it a bore.", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Static page test is what this is for.

Hopefully you don't find it a bore.

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"markdown\\",\\"markdown\\":\\"

Static page test is what this is for.

Hopefully you don't find it a bore.

\\"}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "static-page-test", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "This is a static page", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + ], +} +`; + exports[`Pages API Convert can convert a mobiledoc page to lexical 1: [body] 1`] = ` Object { "pages": Array [ @@ -625,6 +1182,101 @@ Object { } `; +exports[`Pages API Read Re-renders html when null 1: [body] 1`] = ` +Object { + "pages": Array [ + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "Static page test is what this is for. + +Hopefully you don't find it a bore.", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Static page test is what this is for.

Hopefully you don't find it a bore.

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"markdown\\",\\"markdown\\":\\"

Static page test is what this is for.

Hopefully you don't find it a bore.

\\"}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "static-page-test", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "This is a static page", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + ], +} +`; + exports[`Pages API Update Can modify hide_title_and_feature_image property 1: [body] 1`] = ` Object { "pages": Array [ diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap index 5a74f87ac4..dfe7f6503f 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap @@ -2118,6 +2118,14 @@ Object { "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "sort_order": 19, }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 20, + }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 21, + }, ], "slug": "latest", "title": "Latest", @@ -2214,7 +2222,7 @@ exports[`Posts API Update Can add and remove collections 4: [headers] 1`] = ` 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": "5307", + "content-length": "5407", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2337,6 +2345,14 @@ Object { "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "sort_order": 19, }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 20, + }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 21, + }, ], "slug": "latest", "title": "Latest", @@ -2433,7 +2449,7 @@ exports[`Posts API Update Can add and remove collections 6: [headers] 1`] = ` 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": "5301", + "content-length": "5401", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/pages.test.js b/ghost/core/test/e2e-api/admin/pages.test.js index 0e7e26634a..3e85e7dceb 100644 --- a/ghost/core/test/e2e-api/admin/pages.test.js +++ b/ghost/core/test/e2e-api/admin/pages.test.js @@ -1,3 +1,5 @@ +const {mobiledocToLexical} = require('@tryghost/kg-converters'); +const models = require('../../../core/server/models'); const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework'); const {anyArray, anyBoolean, anyContentVersion, anyEtag, anyLocationFor, anyObject, anyObjectId, anyISODateTime, anyString, anyUuid} = matchers; @@ -28,6 +30,7 @@ describe('Pages API', function () { let agent; before(async function () { + mockManager.mockLabsEnabled('collectionsCard'); agent = await agentProvider.getAdminAPIAgent(); await fixtureManager.init('posts'); await agent.loginAsOwner(); @@ -37,6 +40,44 @@ describe('Pages API', function () { mockManager.restore(); }); + describe('Read', function () { + it('Re-renders html when null', async function () { + // "queue" an existing page for re-render as happens when a published page is updated/destroyed + const page = await models.Post.findOne({slug: 'static-page-test'}); + // NOTE: re-rendering only occurs for lexical pages + const lexical = mobiledocToLexical(page.get('mobiledoc')); + await models.Base.knex.raw('UPDATE posts set html=NULL, mobiledoc=NULL, lexical=? WHERE id=?', [lexical, page.id]); + + await agent + .get(`/pages/${page.id}/?formats=mobiledoc,lexical,html`) + .expectStatus(200) + .matchBodySnapshot({ + pages: [Object.assign({}, matchPageShallowIncludes)] + }); + }); + }); + + describe('Browse', function () { + it('Re-renders html when null', async function () { + // convert inserted pages to lexical and set html=null so we can test re-render + const pages = await models.Post.where('type', 'page').fetchAll(); + for (const page of pages) { + if (!page.get('mobiledoc')) { + continue; + } + const lexical = mobiledocToLexical(page.get('mobiledoc')); + await models.Base.knex.raw('UPDATE posts set html=NULL, mobiledoc=NULL, lexical=? WHERE id=?', [lexical, page.id]); + } + + await agent + .get('/pages/?formats=mobiledoc,lexical,html') + .expectStatus(200) + .matchBodySnapshot({ + pages: Array(pages.length).fill(Object.assign({}, matchPageShallowIncludes)) + }); + }); + }); + describe('Create', function () { it('Can create a page with html', async function () { mockManager.mockLabsDisabled('lexicalEditor'); diff --git a/ghost/core/test/e2e-api/admin/posts.test.js b/ghost/core/test/e2e-api/admin/posts.test.js index 66a7daa763..01bd1934c1 100644 --- a/ghost/core/test/e2e-api/admin/posts.test.js +++ b/ghost/core/test/e2e-api/admin/posts.test.js @@ -5,6 +5,7 @@ const {agentProvider, fixtureManager, mockManager, matchers} = require('../../ut const {anyArray, anyContentVersion, anyEtag, anyErrorId, anyLocationFor, anyObject, anyObjectId, anyISODateTime, anyString, anyStringNumber, anyUuid, stringMatching} = matchers; const models = require('../../../core/server/models'); const escapeRegExp = require('lodash/escapeRegExp'); +const {mobiledocToLexical} = require('@tryghost/kg-converters'); const tierSnapshot = { id: anyObjectId, @@ -109,12 +110,23 @@ describe('Posts API', function () { before(async function () { mockManager.mockLabsEnabled('collections', true); + mockManager.mockLabsEnabled('collectionsCard', true); agent = await agentProvider.getAdminAPIAgent(); await fixtureManager.init('posts'); await agent.loginAsOwner(); + + // convert inserted pages to lexical so we can test page.html reset/re-render + const pages = await models.Post.where('type', 'page').fetchAll(); + for (const page of pages) { + const lexical = mobiledocToLexical(page.get('mobiledoc')); + await models.Base.knex.raw('UPDATE posts SET mobiledoc=NULL, lexical=? where id=?', [lexical, page.id]); + } }); - afterEach(function () { + afterEach(async function () { + // gives pages some HTML back to alleviate test interdependence when pages are reset on create/update/delete + await models.Base.knex.raw('update posts set html = "

Testing

" where type = \'page\''); + mockManager.restore(); }); @@ -403,6 +415,50 @@ describe('Posts API', function () { 'content-length': anyStringNumber }); }); + + it('Clears all page html fields when creating published post', async function () { + const totalPageCount = await models.Post.where({type: 'page'}).count(); + should.exist(totalPageCount, 'total page count'); + + // sanity check for pages with no html + const sanityCheckEmptyPageCount = await models.Post.where({html: 'null', type: 'page'}).count(); + should.exist(sanityCheckEmptyPageCount); + sanityCheckEmptyPageCount.should.equal(0, 'initial empty page count'); + + const post = { + title: 'Page reset test', + lexical: createLexical('Testing page.html reset when creating post'), + status: 'published' + }; + + await agent + .post('/posts/?source=html&formats=mobiledoc,lexical,html') + .body({posts: [post]}) + .expectStatus(201); + + // all pages have html cleared + const emptyPageCount = await models.Post.where({html: null, type: 'page'}).count(); + should.exist(emptyPageCount); + emptyPageCount.should.equal(totalPageCount, 'post-creation empty page count'); + }); + + it('Does not clear page html fields when creating draft post', async function () { + const post = { + title: 'Page reset test', + lexical: createLexical('Testing page.html reset when creating post'), + status: 'draft' + }; + + await agent + .post('/posts/?source=html&formats=mobiledoc,lexical,html') + .body({posts: [post]}) + .expectStatus(201); + + // no pages have html cleared + const emptyPageCount = await models.Post.where({html: null, type: 'page'}).count(); + should.exist(emptyPageCount); + emptyPageCount.should.equal(0, 'post-creation empty page count'); + }); }); describe('Update', function () { @@ -583,7 +639,7 @@ describe('Posts API', function () { // collectionToRemove collectionMatcher, // automatic "latest" collection which cannot be removed - buildCollectionMatcher(20) + buildCollectionMatcher(22) ]})] }) .matchHeaderSnapshot({ @@ -601,7 +657,7 @@ describe('Posts API', function () { // collectionToAdd collectionMatcher, // automatic "latest" collection which cannot be removed - buildCollectionMatcher(20) + buildCollectionMatcher(22) ]})] }) .matchHeaderSnapshot({ @@ -610,6 +666,37 @@ describe('Posts API', function () { 'x-cache-invalidate': stringMatching(/\/p\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) }); }); + + it('Clears all page html fields when publishing a post', async function () { + const totalPageCount = await models.Post.where({type: 'page'}).count(); + should.exist(totalPageCount, 'total page count'); + + // sanity check for pages with no html + const sanityCheckEmptyPageCount = await models.Post.where({html: 'null', type: 'page'}).count(); + should.exist(sanityCheckEmptyPageCount); + sanityCheckEmptyPageCount.should.equal(0, 'initial empty page count'); + + const {body: postBody} = await agent + .post('/posts/?source=html&formats=mobiledoc,lexical,html') + .body({posts: [{ + title: 'Page reset test', + lexical: createLexical('Testing page.html reset when updating post'), + status: 'draft' + }]}) + .expectStatus(201); + + const [postResponse] = postBody.posts; + + await agent + .put(`/posts/${postResponse.id}/?source=html&formats=mobiledoc,lexical,html`) + .body({posts: [Object.assign({}, postResponse, {status: 'published'})]}) + .expectStatus(200); + + // all pages have html cleared + const emptyPageCount = await models.Post.where({html: null, type: 'page'}).count(); + should.exist(emptyPageCount); + emptyPageCount.should.equal(totalPageCount, 'post-update empty page count'); + }); }); describe('Delete', function () { @@ -668,6 +755,31 @@ describe('Posts API', function () { }) .matchBodySnapshot(); }); + + it('Clears all page html fields when deleting a published post', async function () { + const totalPageCount = await models.Post.where({type: 'page'}).count(); + should.exist(totalPageCount, 'total page count'); + + // sanity check for pages with no html + const sanityCheckEmptyPageCount = await models.Post.where({html: 'null', type: 'page'}).count(); + should.exist(sanityCheckEmptyPageCount); + sanityCheckEmptyPageCount.should.equal(0, 'initial empty page count'); + + const {body: postBody} = await agent + .get('/posts/?limit=1&filter=status:published') + .expectStatus(200); + + const [postResponse] = postBody.posts; + + await agent + .delete(`/posts/${postResponse.id}/`) + .expectStatus(204); + + // all pages have html cleared + const emptyPageCount = await models.Post.where({html: null, type: 'page'}).count(); + should.exist(emptyPageCount); + emptyPageCount.should.equal(totalPageCount, 'post-deletion empty page count'); + }); }); describe('Copy', function () { diff --git a/ghost/core/test/utils/fixture-utils.js b/ghost/core/test/utils/fixture-utils.js index 6b58cee868..2077465bdb 100644 --- a/ghost/core/test/utils/fixture-utils.js +++ b/ghost/core/test/utils/fixture-utils.js @@ -188,13 +188,15 @@ const fixtures = { throw new Error('Trying to add more posts_tags than the number of posts.'); } - return Promise.all(posts.slice(0, max).map((post) => { - post.tags = post.tags ? post.tags : []; + return models.Base.transaction((transacting) => { + return Promise.all(posts.slice(0, max).map((post) => { + post.tags = post.tags ? post.tags : []; - return models.Post.edit({ - tags: post.tags.concat([_.find(DataGenerator.Content.tags, {id: injectionTagId})]) - }, _.merge({id: post.id}, context.internal)); - })); + return models.Post.edit({ + tags: post.tags.concat([_.find(DataGenerator.Content.tags, {id: injectionTagId})]) + }, _.merge({id: post.id, transacting}, context.internal)); + })); + }); }); },