diff --git a/ghost/external-media-inliner/lib/ExternalMediaInliner.js b/ghost/external-media-inliner/lib/ExternalMediaInliner.js index 4fb5ff69d7..b0572d0158 100644 --- a/ghost/external-media-inliner/lib/ExternalMediaInliner.js +++ b/ghost/external-media-inliner/lib/ExternalMediaInliner.js @@ -129,13 +129,21 @@ class ExternalMediaInliner { } } - async inlineMobiledoc(mobiledoc, domains) { + /** + * Find & inline external media from a JSON sting. + * This works with both Lexical & Mobiledoc, so no separate methods are needed here. + * + * @param {string} content - stringified JSON of post Lexical or Mobiledoc content + * @param {String[]} domains - domains to inline media from + * @returns {Promise} - updated stringified JSON of post content + */ + async inlineContent(content, domains) { for (const domain of domains) { - // NOTE: the src could end with a quote, apostrophe or double-backslash. backlashes are added to mobiledoc + // NOTE: the src could end with a quote, apostrophe or double-backslash. backlashes are added to content // as an escape character const srcTerminationSymbols = `"|'|\\\\`; const regex = new RegExp(`(${domain}.*?)(${srcTerminationSymbols})`, 'igm'); - const matches = mobiledoc.matchAll(regex); + const matches = content.matchAll(regex); for (const [,src] of matches) { const response = await this.getRemoteMedia(src); @@ -151,16 +159,16 @@ class ExternalMediaInliner { if (filePath) { const inlinedSrc = `__GHOST_URL__${filePath}`; - // NOTE: does not account for duplicate images in mobiledoc + // NOTE: does not account for duplicate images in content // in those cases would be processed twice - mobiledoc = mobiledoc.replace(src, inlinedSrc); + content = content.replace(src, inlinedSrc); logging.info(`Inlined media: ${src} -> ${inlinedSrc}`); } } } } - return mobiledoc; + return content; } /** @@ -250,11 +258,27 @@ class ExternalMediaInliner { for (const post of posts) { try { - const inlinedMobiledoc = await this.inlineMobiledoc(post.get('mobiledoc'), domains); + const mobiledocContent = post.get('mobiledoc'); + const lexicalContent = post.get('lexical'); + const updatedFields = await this.inlineFields(post, postsInilingFields, domains); - if (inlinedMobiledoc !== post.get('mobiledoc')) { - updatedFields.mobiledoc = inlinedMobiledoc; + if (mobiledocContent) { + const inlinedContent = await this.inlineContent(mobiledocContent, domains); + + // If content has changed, update the post + if (inlinedContent !== mobiledocContent) { + updatedFields.mobiledoc = inlinedContent; + } + } + + if (lexicalContent) { + const inlinedContent = await this.inlineContent(lexicalContent, domains); + + // If content has changed, update the post + if (inlinedContent !== lexicalContent) { + updatedFields.lexical = inlinedContent; + } } if (Object.keys(updatedFields).length > 0) { diff --git a/ghost/external-media-inliner/test/ExternalMediaInliner.test.js b/ghost/external-media-inliner/test/ExternalMediaInliner.test.js index 7a6ed9528a..aeac60af68 100644 --- a/ghost/external-media-inliner/test/ExternalMediaInliner.test.js +++ b/ghost/external-media-inliner/test/ExternalMediaInliner.test.js @@ -69,11 +69,13 @@ describe('ExternalMediaInliner', function () { .get('/files/f/image.jpg') .reply(200, GIF1x1); + const postStub = sinon.stub(); + postStub.withArgs('mobiledoc').returns(`{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"${imageURL}"}]]}`); + postStub.withArgs('lexical').returns(null); + const postModelInstanceStub = { id: 'inlined-post-id', - get: sinon.stub() - .withArgs('mobiledoc') - .returns(`{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"${imageURL}"}]]}`) + get: postStub }; postModelStub = { findPage: sinon.stub().returns({ @@ -117,11 +119,13 @@ describe('ExternalMediaInliner', function () { .get('/public/images/39719fcb-5af0-4764-bf8b-d375f37a09e5_1141x860') .reply(200, GIF1x1); + const postStub = sinon.stub(); + postStub.withArgs('mobiledoc').returns(`{"version":"0.3.1","atoms":[],"cards":[["html",{"html":"Lorem ipsum"}]],"markups":[],"sections":[[10,0],[1,"p",[]]],"ghostVersion":"4.0"}`); + postStub.withArgs('lexical').returns(null); + const postModelInstanceStub = { id: 'inlined-post-with-htmlcard-id', - get: sinon.stub() - .withArgs('mobiledoc') - .returns(`{"version":"0.3.1","atoms":[],"cards":[["html",{"html":"Lorem ipsum"}]],"markups":[],"sections":[[10,0],[1,"p",[]]],"ghostVersion":"4.0"}`) + get: postStub }; postModelStub = { @@ -161,6 +165,161 @@ describe('ExternalMediaInliner', function () { }); }); + it('inlines image in the post\'s lexical content', async function () { + const imageURL = 'https://img.stockfresh.com/files/f/image.jpg'; + const requestMock = nock('https://img.stockfresh.com') + .get('/files/f/image.jpg') + .reply(200, GIF1x1); + + const postStub = sinon.stub(); + postStub.withArgs('mobiledoc').returns(null); + postStub.withArgs('lexical').returns(`{"root":{"children":[{"type":"image","version":1,"src":"${imageURL}","width":1480,"height":486,"title":"","alt":"","caption":"","cardWidth":"regular","href":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`); + + const postModelInstanceStub = { + id: 'inlined-post-id', + get: postStub + }; + postModelStub = { + findPage: sinon.stub().returns({ + data: [postModelInstanceStub] + }), + edit: sinon.stub().resolves() + }; + + sinon.stub(path, 'relative') + .withArgs('/content/images', '/content/images/unique-image.jpg') + .returns('unique-image.jpg'); + const inliner = new ExternalMediaInliner({ + PostModel: postModelStub, + PostMetaModel: postMetaModelStub, + TagModel: tagModelStub, + UserModel: userModelStub, + getMediaStorage: sinon.stub().withArgs('.jpg').returns({ + getTargetDir: () => '/content/images', + getUniqueFileName: () => '/content/images/unique-image.jpg', + saveRaw: () => '/content/images/unique-image.jpg' + }) + }); + + await inliner.inline(['https://img.stockfresh.com']); + + assert.ok(requestMock.isDone()); + assert.ok(postModelStub.edit.calledOnce); + assert.ok(postModelStub.edit.calledWith({ + lexical: '{"root":{"children":[{"type":"image","version":1,"src":"__GHOST_URL__/content/images/unique-image.jpg","width":1480,"height":486,"title":"","alt":"","caption":"","cardWidth":"regular","href":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' + }, { + id: 'inlined-post-id', + context: { + internal: true + } + })); + }); + + it('inlines the image from post\'s lexical containing html card', async function () { + const imageURL = 'https://img.stockfresh.com/files/f/image.jpg'; + const requestMock = nock('https://img.stockfresh.com') + .get('/files/f/image.jpg') + .reply(200, GIF1x1); + + const postStub = sinon.stub(); + postStub.withArgs('mobiledoc').returns(null); + postStub.withArgs('lexical').returns(`{"root":{"children":[{"type":"html","version":1,"html":"Lorem ipsum"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`); + + const postModelInstanceStub = { + id: 'inlined-post-with-htmlcard-id', + get: postStub + }; + + postModelStub = { + findPage: sinon.stub().returns({ + data: [postModelInstanceStub] + }), + edit: sinon.stub().resolves() + }; + + sinon.stub(path, 'relative') + .withArgs('/content/images', '/content/images/unique-image.jpg') + .returns('unique-image.jpg'); + const inliner = new ExternalMediaInliner({ + PostModel: postModelStub, + PostMetaModel: postMetaModelStub, + TagModel: tagModelStub, + UserModel: userModelStub, + getMediaStorage: sinon.stub().withArgs('.jpg').returns({ + getTargetDir: () => '/content/images', + getUniqueFileName: () => '/content/images/unique-image.jpg', + saveRaw: () => '/content/images/unique-image.jpg' + }) + }); + + await inliner.inline(['https://img.stockfresh.com']); + + assert.ok(requestMock.isDone()); + assert.ok(postModelStub.edit.calledOnce); + assert.deepEqual(postModelStub.edit.args[0][0], { + lexical: `{"root":{"children":[{"type":"html","version":1,"html":"Lorem ipsum"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}` + }); + assert.deepEqual(postModelStub.edit.args[0][1], { + id: 'inlined-post-with-htmlcard-id', + context: { + internal: true + } + }); + }); + + it('inlines image in the post\'s mobiledoc & lexical content', async function () { + const imageURL = 'https://img.stockfresh.com/files/f/image.jpg'; + const requestMock = nock('https://img.stockfresh.com') + .get('/files/f/image.jpg') + .reply(200, GIF1x1) + .get('/files/f/image.jpg') + .reply(200, GIF1x1); + + const postStub = sinon.stub(); + postStub.withArgs('mobiledoc').returns(`{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"${imageURL}"}]]}`); + postStub.withArgs('lexical').returns(`{"root":{"children":[{"type":"image","version":1,"src":"${imageURL}","width":1480,"height":486,"title":"","alt":"","caption":"","cardWidth":"regular","href":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`); + + const postModelInstanceStub = { + id: 'inlined-post-id', + get: postStub + }; + postModelStub = { + findPage: sinon.stub().returns({ + data: [postModelInstanceStub] + }), + edit: sinon.stub().resolves() + }; + + sinon.stub(path, 'relative') + .withArgs('/content/images', '/content/images/unique-image.jpg') + .returns('unique-image.jpg'); + const inliner = new ExternalMediaInliner({ + PostModel: postModelStub, + PostMetaModel: postMetaModelStub, + TagModel: tagModelStub, + UserModel: userModelStub, + getMediaStorage: sinon.stub().withArgs('.jpg').returns({ + getTargetDir: () => '/content/images', + getUniqueFileName: () => '/content/images/unique-image.jpg', + saveRaw: () => '/content/images/unique-image.jpg' + }) + }); + + await inliner.inline(['https://img.stockfresh.com']); + + assert.ok(requestMock.isDone()); + assert.ok(postModelStub.edit.calledOnce); + assert.ok(postModelStub.edit.calledWith({ + mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"__GHOST_URL__/content/images/unique-image.jpg"}]]}', + lexical: '{"root":{"children":[{"type":"image","version":1,"src":"__GHOST_URL__/content/images/unique-image.jpg","width":1480,"height":486,"title":"","alt":"","caption":"","cardWidth":"regular","href":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' + }, { + id: 'inlined-post-id', + context: { + internal: true + } + })); + }); + it('logs an error when fetching an external media fails', async function () { const imageURL = 'https://img.stockfresh.com/files/f/image.jpg'; const requestMock = nock('https://img.stockfresh.com') @@ -262,11 +421,13 @@ describe('ExternalMediaInliner', function () { .get('/files/f/image.jpg') .reply(200, GIF1x1); + const postStub = sinon.stub(); + postStub.withArgs('mobiledoc').returns(`{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"${imageURL}"}]]}`); + postStub.withArgs('lexical').returns(null); + postModelStub = { id: 'errored-post-id', - get: sinon.stub() - .withArgs('mobiledoc') - .returns(`{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"${imageURL}"}]]}`) + get: postStub }; postModelStub = { findPage: sinon.stub().returns({