Added page html reset and re-render to handle dynamic collection card content (#18265)

no issue

Collection cards contain dynamic data that can change when there's any change to a published post but in Ghost all post/page content is rendered once on save and stored as a static string meaning we need a new approach for triggering a re-render of pages that plays well with caching.

- fixed typo in the relations/authors code that meant we weren't correctly calling the prototype method on the Post model inside the `onFetchedCollection` event handler
- updated Post model to clear the `html` field of all pages when saving or deleting a published post
- updated Post model to re-render `html` fields when fetching individual posts or a collection of posts
- modified `insertExtraPostsTags` fixture util to wrap it's concurrent post edits in a transaction otherwise MySQL errors because it hits a deadlock
This commit is contained in:
Kevin Ansfield 2023-09-21 15:51:08 +01:00 committed by GitHub
parent 098f4353a7
commit f98467611c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 906 additions and 13 deletions

View File

@ -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);
},

View File

@ -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) {

View File

@ -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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"markdown\\",\\"markdown\\":\\"<h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p>\\"}],\\"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "static-page-draft",
"status": "draft",
"tags": Any<Array>,
"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<String>,
"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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<p>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.</p><p>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:</p><ul><li>Contribute code via <a href=\\"https://github.com/tryghost\\">GitHub</a></li><li>Contribute financially via <a href=\\"https://github.com/sponsors/TryGhost\\">GitHub Sponsors</a></li><li>Contribute financially via <a href=\\"https://opencollective.com/ghost\\">Open Collective</a></li><li>Contribute reviews via <strong>writing a blog post</strong></li><li>Contribute good vibes via <strong>telling your friends</strong> about us</li></ul><p>Thanks for checking us out!</p>",
"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "contribute",
"status": "published",
"tags": Any<Array>,
"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<String>,
"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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<p>Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind.</p><p>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.</p>",
"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "privacy",
"status": "published",
"tags": Any<Array>,
"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<String>,
"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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<p>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.</p><h3 id=\\"for-example-heres-how-to-reach-us\\">For example, here's how to reach us!</h3><ul><li><a href=\\"https://twitter.com/ghost\\">@Ghost</a> on Twitter</li><li><a href=\\"https://www.facebook.com/ghost\\">@Ghost</a> on Facebook</li><li><a href=\\"https://instagram.com/ghost\\">@Ghost</a> on Instagram</li></ul><p>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:</p><figure class=\\"kg-card kg-image-card\\"><a href=\\"https://ghost.org/integrations/?tag=forms\\"><img src=\\"https://static.ghost.org/v4.0.0/images/integrations.png\\" class=\\"kg-image\\" alt=\\"\\" loading=\\"lazy\\" width=\\"2944\\" height=\\"1716\\"></a></figure>",
"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "contact",
"status": "published",
"tags": Any<Array>,
"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<String>,
"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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<p>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.</p><p>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!</p><blockquote><strong>Tip: </strong>If you're reading any post or page on your site and you notice something you want to edit, you can add <code>/edit</code> to the end of the URL and you'll be taken directly to the Ghost editor.</blockquote><p>Now tell the world what your site is all about.</p>",
"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "about",
"status": "published",
"tags": Any<Array>,
"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<String>,
"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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"markdown\\",\\"markdown\\":\\"<h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p>\\"}],\\"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "static-page-test",
"status": "published",
"tags": Any<Array>,
"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<String>,
"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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"markdown\\",\\"markdown\\":\\"<h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p>\\"}],\\"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "static-page-test",
"status": "published",
"tags": Any<Array>,
"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<String>,
"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 [

View File

@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -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');

View File

@ -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 = "<p>Testing</p>" 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 () {

View File

@ -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));
}));
});
});
},