From 8c91662a47b38a1a37b1ccd6c92932cffeeb8130 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 6 Sep 2023 22:16:40 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20conversion=20to=20beta=20ed?= =?UTF-8?q?itor=20format=20when=20creating=20content=20via=20`=3Fsource=3D?= =?UTF-8?q?html`=20(#18000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/TryGhost/Product/issues/3803 Previously when the beta editor was enabled, using `?source=html` to create posts via the API would create posts in the old editor rather than the beta. This change switches conversion over to the new editor format when the beta is enabled so the full flow can be tested. - added `htmlToLexicalConverter` method to our lexical library - updated post and page input serializers to add html-to-lexical conversion when the beta editor is enabled - updated post model to handle the mobiledoc+lexical co-existing state - this is a special case that is only valid for `?source=html` because providing both directly via the API is prohibited - we need the extra check here because at the input serializer layer we don't have access to the model to check if we're updating a mobiledoc post or a lexical post so the serializer sets both formats on a `?source=html` request when the beta is enabled and lets the model handle choosing the correct one --- .../utils/serializers/input/pages.js | 8 + .../utils/serializers/input/posts.js | 10 +- ghost/core/core/server/lib/lexical.js | 15 + ghost/core/core/server/models/post.js | 12 +- ghost/core/package.json | 1 + .../admin/__snapshots__/pages.test.js.snap | 321 ++++++++++++++++++ .../admin/__snapshots__/posts.test.js.snap | 265 +++++++++++++-- ghost/core/test/e2e-api/admin/pages.test.js | 48 ++- ghost/core/test/e2e-api/admin/posts.test.js | 50 ++- yarn.lock | 15 +- 10 files changed, 711 insertions(+), 34 deletions(-) diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/pages.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/pages.js index 820e9dba6e..03dcd3069d 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/pages.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/pages.js @@ -6,6 +6,8 @@ const slugFilterOrder = require('./utils/slug-filter-order'); const localUtils = require('../../index'); const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta; const clean = require('./utils/clean'); +const labs = require('../../../../../../shared/labs'); +const lexical = require('../../../../../lib/lexical'); function removeSourceFormats(frame) { if (frame.options.formats?.includes('mobiledoc') || frame.options.formats?.includes('lexical')) { @@ -133,6 +135,12 @@ module.exports = { if (frame.options.source === 'html' && !_.isEmpty(html)) { frame.data.pages[0].mobiledoc = JSON.stringify(mobiledoc.htmlToMobiledocConverter(html)); + + // normally we don't allow both mobiledoc+lexical but the model layer will remove lexical + // if mobiledoc is already present to avoid migrating formats outside of an explicit conversion + if (labs.isSet('lexicalEditor')) { + frame.data.pages[0].lexical = JSON.stringify(lexical.htmlToLexicalConverter(html)); + } } } diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js index 3b456074ca..7bb2d6b394 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js @@ -6,6 +6,8 @@ const localUtils = require('../../index'); const mobiledoc = require('../../../../../lib/mobiledoc'); const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta; const clean = require('./utils/clean'); +const labs = require('../../../../../../shared/labs'); +const lexical = require('../../../../../lib/lexical'); function removeSourceFormats(frame) { if (frame.options.formats?.includes('mobiledoc') || frame.options.formats?.includes('lexical')) { @@ -32,7 +34,7 @@ function defaultRelations(frame) { // Apply same mapping as content API mapWithRelated(frame); - // Addditional defaults for admin API + // Additional defaults for admin API if (frame.options.withRelated) { return; } @@ -167,6 +169,12 @@ module.exports = { if (frame.options.source === 'html' && !_.isEmpty(html)) { frame.data.posts[0].mobiledoc = JSON.stringify(mobiledoc.htmlToMobiledocConverter(html)); + + // normally we don't allow both mobiledoc+lexical but the model layer will remove lexical + // if mobiledoc is already present to avoid migrating formats outside of an explicit conversion + if (labs.isSet('lexicalEditor')) { + frame.data.posts[0].lexical = JSON.stringify(lexical.htmlToLexicalConverter(html)); + } } } diff --git a/ghost/core/core/server/lib/lexical.js b/ghost/core/core/server/lib/lexical.js index 4e20d33d0e..4e230fba1a 100644 --- a/ghost/core/core/server/lib/lexical.js +++ b/ghost/core/core/server/lib/lexical.js @@ -1,4 +1,5 @@ const path = require('path'); +const errors = require('@tryghost/errors'); const urlUtils = require('../../shared/url-utils'); const config = require('../../shared/config'); const storage = require('../adapters/storage'); @@ -78,5 +79,19 @@ module.exports = { } return urlTransformMap; + }, + + get htmlToLexicalConverter() { + try { + return require('@tryghost/kg-html-to-lexical').htmlToLexical; + } catch (err) { + throw new errors.InternalServerError({ + message: 'Unable to convert from source HTML to Lexical', + context: 'The html-to-lexical package was not installed', + help: 'Please review any errors from the install process by checking the Ghost logs', + code: 'HTML_TO_LEXICAL_INSTALLATION', + err: err + }); + } } }; diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index 1d6cebdaf6..7f0289ebd8 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -552,6 +552,16 @@ Post = ghostBookshelf.Model.extend({ let tagsToSave; const ops = []; + // normally we don't allow both mobiledoc & lexical through at the API level but there's + // an exception for ?source=html which always sets both when the lexical editor is enabled. + // That's necessary because at the input serializer layer we don't have access to the + // actual model to check if this would result in a change of format + if (this.previous('mobiledoc') && this.get('lexical')) { + this.set('lexical', null); + } else if (this.get('mobiledoc') && this.get('lexical')) { + this.set('mobiledoc', null); + } + // CASE: disallow published -> scheduled // @TODO: remove when we have versioning based on updated_at if (newStatus !== olderStatus && newStatus === 'scheduled' && olderStatus === 'published') { @@ -653,7 +663,7 @@ Post = ghostBookshelf.Model.extend({ // If we're force re-rendering we want to make sure that all image cards // have original dimensions stored in the payload for use by card renderers - if (options.force_rerender) { + if (options.force_rerender && this.get('mobiledoc')) { this.set('mobiledoc', await mobiledocLib.populateImageSizes(this.get('mobiledoc'))); } diff --git a/ghost/core/package.json b/ghost/core/package.json index 3d23b85dee..adf584e241 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -105,6 +105,7 @@ "@tryghost/kg-default-atoms": "4.0.3", "@tryghost/kg-default-cards": "9.1.4", "@tryghost/kg-default-nodes": "0.1.26", + "@tryghost/kg-html-to-lexical": "0.0.1", "@tryghost/kg-lexical-html-renderer": "0.3.22", "@tryghost/kg-mobiledoc-html-renderer": "6.0.10", "@tryghost/limit-service": "1.2.6", 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 99d315ef0f..165bfe0f38 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 @@ -304,6 +304,327 @@ Object { } `; +exports[`Pages API Create Can create a page with html (labs.lexicalEditor) 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": "Testing page creation with html", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Testing page creation with html

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Testing page creation with html\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"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": null, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "html-test-2", + "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": "HTML test", + "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 Create Can create a page with html (labs.lexicalEditor) 2: [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": "5420", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/pages\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Pages API Create Can create a page with html 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": "Testing page creation with html", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Testing page creation with html

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": null, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[],\\"markups\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"Testing page creation with html\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": null, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "html-test", + "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": "HTML test", + "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 Create Can create a page with html 2: [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": "3815", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/pages\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Pages API Create Can create a post with html (labs.lexicalEditor) 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": "Testing page creation with html", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Testing page creation with html

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Testing page creation with html\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"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": null, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "html-test-2", + "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": "HTML test", + "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 Create Can create a post with html (labs.lexicalEditor) 2: [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": "5420", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/pages\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + 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 c0fca1e378..cf64351b0f 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 @@ -534,19 +534,6 @@ Object { } `; -exports[`Posts API Can browse filtering by collection using paging parameters 1: [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": "7639", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - exports[`Posts API Can browse filtering by collection using paging parameters 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", @@ -1008,6 +995,226 @@ Object { } `; +exports[`Posts API Create Can create a post with html (labs.lexicalEditor) 1: [body] 1`] = ` +Object { + "posts": Array [ + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "clicks": 0, + "negative_feedback": 0, + "positive_feedback": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "Testing post creation with html", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Testing post creation with html

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Testing post creation with html\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "newsletter": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": null, + "reading_time": 0, + "slug": "html-test-2", + "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": "HTML test", + "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[`Posts API Create Can create a post with html (labs.lexicalEditor) 2: [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": "5455", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Posts API Create Can create a post with html 1: [body] 1`] = ` +Object { + "posts": Array [ + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "clicks": 0, + "negative_feedback": 0, + "positive_feedback": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "Testing post creation with html", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

Testing post creation with html

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": null, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[],\\"markups\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"Testing post creation with html\\"]]]]}", + "newsletter": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": null, + "reading_time": 0, + "slug": "html-test", + "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": "HTML test", + "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[`Posts API Create Can create a post with html 2: [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": "3850", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Posts API Create Can create a post with lexical 1: [body] 1`] = ` Object { "posts": Array [ @@ -1302,18 +1509,6 @@ Object { } `; -exports[`Posts API Delete Can destroy a post 2: [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-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - exports[`Posts API Delete Cannot delete a non-existent posts 1: [body] 1`] = ` Object { "errors": Array [ @@ -1662,6 +1857,14 @@ Object { "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "sort_order": 17, }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 18, + }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 19, + }, ], "slug": "latest", "title": "Latest", @@ -1758,7 +1961,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": "5207", + "content-length": "5307", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1873,6 +2076,14 @@ Object { "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "sort_order": 17, }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 18, + }, + Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "sort_order": 19, + }, ], "slug": "latest", "title": "Latest", @@ -1969,7 +2180,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": "5201", + "content-length": "5301", "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 bed4220aaf..0e7e26634a 100644 --- a/ghost/core/test/e2e-api/admin/pages.test.js +++ b/ghost/core/test/e2e-api/admin/pages.test.js @@ -37,6 +37,52 @@ describe('Pages API', function () { mockManager.restore(); }); + describe('Create', function () { + it('Can create a page with html', async function () { + mockManager.mockLabsDisabled('lexicalEditor'); + + const page = { + title: 'HTML test', + html: '

Testing page creation with html

' + }; + + await agent + .post('/pages/?source=html&formats=mobiledoc,lexical,html') + .body({pages: [page]}) + .expectStatus(201) + .matchBodySnapshot({ + pages: [Object.assign({}, matchPageShallowIncludes, {published_at: null})] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('pages') + }); + }); + + it('Can create a page with html (labs.lexicalEditor)', async function () { + mockManager.mockLabsEnabled('lexicalEditor'); + + const page = { + title: 'HTML test', + html: '

Testing page creation with html

' + }; + + await agent + .post('/pages/?source=html&formats=mobiledoc,lexical,html') + .body({pages: [page]}) + .expectStatus(201) + .matchBodySnapshot({ + pages: [Object.assign({}, matchPageShallowIncludes, {published_at: null})] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('pages') + }); + }); + }); + describe('Update', function () { it('Can modify show_title_and_feature_image property', async function () { const page = { @@ -115,7 +161,7 @@ describe('Pages API', function () { }); describe('Convert', function () { - it('can convert a mobiledoc page to lexical', async function () { + it('can convert a mobiledoc page to lexical', async function () { const mobiledoc = JSON.stringify({ version: '0.3.1', ghostVersion: '4.0', diff --git a/ghost/core/test/e2e-api/admin/posts.test.js b/ghost/core/test/e2e-api/admin/posts.test.js index c7d40e2d3f..15a704e905 100644 --- a/ghost/core/test/e2e-api/admin/posts.test.js +++ b/ghost/core/test/e2e-api/admin/posts.test.js @@ -312,6 +312,50 @@ describe('Posts API', function () { mobiledocRevisions.length.should.equal(0); }); + it('Can create a post with html', async function () { + mockManager.mockLabsDisabled('lexicalEditor'); + + const post = { + title: 'HTML test', + html: '

Testing post creation with html

' + }; + + await agent + .post('/posts/?source=html&formats=mobiledoc,lexical,html') + .body({posts: [post]}) + .expectStatus(201) + .matchBodySnapshot({ + posts: [Object.assign({}, matchPostShallowIncludes, {published_at: null})] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('posts') + }); + }); + + it('Can create a post with html (labs.lexicalEditor)', async function () { + mockManager.mockLabsEnabled('lexicalEditor'); + + const post = { + title: 'HTML test', + html: '

Testing post creation with html

' + }; + + await agent + .post('/posts/?source=html&formats=mobiledoc,lexical,html') + .body({posts: [post]}) + .expectStatus(201) + .matchBodySnapshot({ + posts: [Object.assign({}, matchPostShallowIncludes, {published_at: null})] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('posts') + }); + }); + it('Errors if both mobiledoc and lexical are present', async function () { const post = { title: 'Mobiledoc+lexical test', @@ -538,7 +582,7 @@ describe('Posts API', function () { // collectionToRemove collectionMatcher, // automatic "latest" collection which cannot be removed - buildCollectionMatcher(18) + buildCollectionMatcher(20) ]})] }) .matchHeaderSnapshot({ @@ -556,7 +600,7 @@ describe('Posts API', function () { // collectionToAdd collectionMatcher, // automatic "latest" collection which cannot be removed - buildCollectionMatcher(18) + buildCollectionMatcher(20) ]})] }) .matchHeaderSnapshot({ @@ -630,7 +674,7 @@ describe('Posts API', function () { }); describe('Convert', function () { - it('can convert a mobiledoc post to lexical', async function () { + it('can convert a mobiledoc post to lexical', async function () { const mobiledoc = createMobiledoc('This is some great content.'); const expectedLexical = createLexical('This is some great content.'); const postData = { diff --git a/yarn.lock b/yarn.lock index 36fbdf40ac..905b2d53a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4056,7 +4056,7 @@ resolved "https://registry.yarnpkg.com/@lexical/headless/-/headless-0.12.0.tgz#8ee540b6c56369688a368db1cd9eac8fa59d2203" integrity sha512-X9LwOXs5xJV4LY1uDRFwYadbY8D8gFfFR3+ios/ujLNuQ817k1CngCyyj4XIvSnvBqC+RYy8ridztjIlfhuNJA== -"@lexical/html@0.12.0": +"@lexical/html@0.12.0", "@lexical/html@^0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.12.0.tgz#008929ef2ee7114772c00bb1fd07df721f0e6e53" integrity sha512-90KKtMDMElS7AD3nYzknYe6eiu7/lw1neXFWc74XI4YLiYBD14Pb/FrEvGnQ+P1voGtr4sPk3KEf8huHdM9sJw== @@ -7852,6 +7852,19 @@ lodash "^4.17.21" luxon "^3.3.0" +"@tryghost/kg-html-to-lexical@0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@tryghost/kg-html-to-lexical/-/kg-html-to-lexical-0.0.1.tgz#4386c19330a64c7c9a10c54d8e47ed6f41289676" + integrity sha512-J+3A335QHu0xWvklntxAPfAPByqXG9qzEb+9f+eG01HDXHcb4cCRILjdtTmUh2ipLkXXWM9KFf5r/tlTNq7ChQ== + dependencies: + "@lexical/headless" "^0.12.0" + "@lexical/html" "^0.12.0" + "@lexical/link" "^0.12.0" + "@lexical/list" "^0.12.0" + "@tryghost/kg-default-nodes" "^0.1.26" + jsdom "^22.1.0" + lexical "^0.12.0" + "@tryghost/kg-lexical-html-renderer@0.3.22": version "0.3.22" resolved "https://registry.yarnpkg.com/@tryghost/kg-lexical-html-renderer/-/kg-lexical-html-renderer-0.3.22.tgz#b93dc849cd7386fbcbc751561399a4bd449def9d"