diff --git a/core/server/lib/mobiledoc.js b/core/server/lib/mobiledoc.js index 6ef6681241..237a2360e1 100644 --- a/core/server/lib/mobiledoc.js +++ b/core/server/lib/mobiledoc.js @@ -1,6 +1,9 @@ +const path = require('path'); const errors = require('@tryghost/errors'); +const imageTransform = require('@tryghost/image-transform'); const logging = require('../../shared/logging'); const config = require('../../shared/config'); +const storage = require('../adapters/storage'); let cardFactory; let cards; @@ -78,6 +81,83 @@ module.exports = { } }, + // used when force-rerendering post content to ensure that old image card + // payloads contain width/height values to be used when generating srcsets + populateImageSizes: async function (mobiledocJson) { + // do not require image-size until it's requested to avoid circular dependencies + // shared/url-utils > server/lib/mobiledoc > server/lib/image/image-size > server/adapters/storage/utils + const imageSize = require('./image/image-size'); + const urlUtils = require('../../shared/url-utils'); + const storageInstance = storage.getStorage(); + + async function getUnsplashSize(url) { + const parsedUrl = new URL(url); + parsedUrl.searchParams.delete('w'); + parsedUrl.searchParams.delete('fit'); + parsedUrl.searchParams.delete('crop'); + parsedUrl.searchParams.delete('dpr'); + + return await imageSize.getImageSizeFromUrl(parsedUrl.href); + } + + // TODO: extract conditional logic lifted from handle-image-sizes.js + async function getLocalSize(url) { + // skip local images if adapter doesn't support size transforms + if (typeof storageInstance.saveRaw !== 'function') { + return; + } + + // local storage adapter's .exists() expects image paths without any prefixes + const imageUrlPrefix = urlUtils.urlJoin(urlUtils.getSubdir(), urlUtils.STATIC_IMAGE_URL_PREFIX); + const storagePath = url.replace(imageUrlPrefix, ''); + + const {dir, name, ext} = path.parse(storagePath); + const [imageNameMatched, imageName, imageNumber] = name.match(/^(.+?)(-\d+)?$/) || [null]; + + if (!imageNameMatched + || !imageTransform.canTransformFileExtension(ext) + || !(await storageInstance.exists(storagePath)) + ) { + return; + } + + // get the original/unoptimized image if it exists as that will have + // the maximum dimensions that srcset/handle-image-sizes can use + const originalImagePath = path.join(dir, `${imageName}_o${imageNumber || ''}${ext}`); + const imagePath = await storageInstance.exists(originalImagePath) ? originalImagePath : storagePath; + + return await imageSize.getImageSizeFromStoragePath(imagePath); + } + + const mobiledoc = JSON.parse(mobiledocJson); + + const sizePromises = mobiledoc.cards.map(async (card) => { + const [cardName, payload] = card; + + const needsFilling = cardName === 'image' && (!payload.width || !payload.height); + if (!needsFilling) { + return; + } + + const isUnsplash = payload.src.match(/images\.unsplash\.com/); + try { + const size = isUnsplash ? await getUnsplashSize(payload.src) : await getLocalSize(payload.src); + + if (size && size.width && size.height) { + payload.width = size.width; + payload.height = size.height; + } + } catch (e) { + // TODO: use debug instead? + logging.error(e); + } + }); + + await Promise.all(sizePromises); + + return JSON.stringify(mobiledoc); + }, + // allow config changes to be picked up - useful in tests reload() { cardFactory = null; diff --git a/core/server/models/post.js b/core/server/models/post.js index c8e0f42865..68f9e5e374 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -404,6 +404,12 @@ 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) { + this.set('mobiledoc', mobiledocLib.populateImageDimensions(this.get('mobiledoc'))); + } + // CASE: mobiledoc has changed, generate html // CASE: ?force_rerender=true passed via Admin API // CASE: html is null, but mobiledoc exists (only important for migrations & importing) diff --git a/core/shared/url-utils.js b/core/shared/url-utils.js index ddacc9dafb..7df4d69e2c 100644 --- a/core/shared/url-utils.js +++ b/core/shared/url-utils.js @@ -1,6 +1,5 @@ const UrlUtils = require('@tryghost/url-utils'); const config = require('./config'); -const mobiledoc = require('../server/lib/mobiledoc'); const urlUtils = new UrlUtils({ url: config.get('url'), @@ -11,6 +10,9 @@ const urlUtils = new UrlUtils({ redirectCacheMaxAge: config.get('caching:301:maxAge'), baseApiPath: '/ghost/api', get cardTransformers() { + // do not require mobiledoc until it's requested to avoid circular dependencies + // shared/url-utils > server/lib/mobiledoc > server/lib/image/image-size > server/adapters/storage/utils + const mobiledoc = require('../server/lib/mobiledoc'); return mobiledoc.cards; } }); diff --git a/test/unit/lib/mobiledoc_spec.js b/test/unit/lib/mobiledoc_spec.js index 2d16c8681a..6b15780c26 100644 --- a/test/unit/lib/mobiledoc_spec.js +++ b/test/unit/lib/mobiledoc_spec.js @@ -1,6 +1,9 @@ +const path = require('path'); const should = require('should'); +const nock = require('nock'); const configUtils = require('../../utils/configUtils'); const mobiledocLib = require('../../../core/server/lib/mobiledoc'); +const storage = require('../../../core/server/adapters/storage'); describe('lib/mobiledoc', function () { beforeEach(function () { @@ -8,6 +11,7 @@ describe('lib/mobiledoc', function () { }); afterEach(function () { + nock.cleanAll(); configUtils.restore(); // ensure config changes are reset and picked up by next test mobiledocLib.reload(); @@ -110,4 +114,54 @@ describe('lib/mobiledoc', function () { .should.eql('
Birdies
'); }); }); + + describe('populateImageSizes', function () { + let originalStoragePath; + + beforeEach(function () { + originalStoragePath = storage.getStorage().storagePath; + storage.getStorage().storagePath = path.join(__dirname, '../../utils/fixtures/images/'); + }); + + afterEach(function () { + storage.getStorage().storagePath = originalStoragePath; + }); + + it('works', async function () { + let mobiledoc = { + cards: [ + ['image', {src: '/content/images/ghost-logo.png'}], + ['image', {src: 'http://example.com/external.jpg'}], + ['image', {src: 'https://images.unsplash.com/favicon_too_large?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=2000&fit=max&ixid=eyJhcHBfaWQiOjExNzczfQ'}] + ] + }; + + const unsplashMock = nock('https://images.unsplash.com/') + .get('/favicon_too_large') + .query(true) + .replyWithFile(200, path.join(__dirname, '../../utils/fixtures/images/favicon_not_square.png'), { + 'Content-Type': 'image/png' + }); + + const transformedMobiledoc = await mobiledocLib.populateImageSizes(JSON.stringify(mobiledoc)); + const transformed = JSON.parse(transformedMobiledoc); + + unsplashMock.isDone().should.be.true(); + + transformed.cards.length.should.equal(3); + + should.exist(transformed.cards[0][1].width); + transformed.cards[0][1].width.should.equal(800); + should.exist(transformed.cards[0][1].height); + transformed.cards[0][1].height.should.equal(257); + + should.not.exist(transformed.cards[1][1].width); + should.not.exist(transformed.cards[1][1].height); + + should.exist(transformed.cards[2][1].width); + transformed.cards[2][1].width.should.equal(100); + should.exist(transformed.cards[2][1].height); + transformed.cards[2][1].height.should.equal(80); + }); + }); });