diff --git a/aio/package.json b/aio/package.json index e07bd2521c..16c65b81ed 100644 --- a/aio/package.json +++ b/aio/package.json @@ -68,8 +68,10 @@ "firebase-tools": "^3.2.1", "fs-extra": "^2.1.2", "globby": "^6.1.0", + "hast-util-is-element": "^1.0.0", "html": "^1.0.0", "http-server": "^0.9.0", + "image-size": "^0.5.1", "jasmine-core": "~2.5.2", "jasmine-spec-reporter": "~3.2.0", "jsdom": "^9.12.0", @@ -94,6 +96,8 @@ "ts-node": "~2.0.0", "tslint": "~4.5.0", "typescript": "2.2.0", + "unist-util-source": "^1.0.1", + "unist-util-visit": "^1.1.1", "vrsource-tslint-rules": "^4.0.1", "watchr": "^3.0.1", "yargs": "^7.0.2" diff --git a/aio/tools/transforms/angular-base-package/index.js b/aio/tools/transforms/angular-base-package/index.js index 9015325dc4..58580d02bb 100644 --- a/aio/tools/transforms/angular-base-package/index.js +++ b/aio/tools/transforms/angular-base-package/index.js @@ -34,6 +34,9 @@ module.exports = new Package('angular-base', [ .factory('packageInfo', function() { return require(path.resolve(PROJECT_ROOT, 'package.json')); }) .factory(require('./readers/json')) .factory(require('./services/copyFolder')) + .factory(require('./services/getImageDimensions')) + + .factory(require('./post-processors/add-image-dimensions')) .config(function(checkAnchorLinksProcessor) { // This is disabled here to prevent false negatives for the `docs-watch` task. @@ -120,9 +123,11 @@ module.exports = new Package('angular-base', [ }) - .config(function(postProcessHtml) { + .config(function(postProcessHtml, addImageDimensions) { + addImageDimensions.basePath = path.resolve(AIO_PATH, 'src'); postProcessHtml.plugins = [ - require('./post-processors/autolink-headings') + require('./post-processors/autolink-headings'), + addImageDimensions ]; }) diff --git a/aio/tools/transforms/angular-base-package/post-processors/add-image-dimensions.js b/aio/tools/transforms/angular-base-package/post-processors/add-image-dimensions.js new file mode 100644 index 0000000000..1a068d00ee --- /dev/null +++ b/aio/tools/transforms/angular-base-package/post-processors/add-image-dimensions.js @@ -0,0 +1,40 @@ +const visit = require('unist-util-visit'); +const is = require('hast-util-is-element'); +const source = require('unist-util-source'); + +/** + * Add the width and height of the image to the `img` tag if they are + * not already provided. This helps prevent jank when the page is + * rendered before the image has downloaded. + * + * If there is no `src` attribute on an image, or it is not possible + * to load the image file indicated by the `src` then a warning is emitted. + */ +module.exports = function addImageDimensions(getImageDimensions) { + return function addImageDimensionsImpl() { + return (ast, file) => { + visit(ast, node => { + + if (is(node, 'img')) { + const props = node.properties; + const src = props.src; + if (!src) { + file.message('Missing src in image tag `' + source(node, file) + '`'); + } else if (props.width === undefined && props.height === undefined) { + try { + const dimensions = getImageDimensions(addImageDimensionsImpl.basePath, src); + props.width = '' + dimensions.width; + props.height = '' + dimensions.height; + } catch(e) { + if (e.code === 'ENOENT') { + file.message('Unable to load src in image tag `' + source(node, file) + '`'); + } else { + file.fail(e.message); + } + } + } + } + }); + }; + }; +}; diff --git a/aio/tools/transforms/angular-base-package/post-processors/add-image-dimensions.spec.js b/aio/tools/transforms/angular-base-package/post-processors/add-image-dimensions.spec.js new file mode 100644 index 0000000000..965871f6b0 --- /dev/null +++ b/aio/tools/transforms/angular-base-package/post-processors/add-image-dimensions.spec.js @@ -0,0 +1,110 @@ +var createTestPackage = require('../../helpers/test-package'); +var Dgeni = require('dgeni'); + +describe('addImageDimensions post-processor', () => { + let processor, getImageDimensionsSpy, addImageDimensions, log; + + beforeEach(() => { + const testPackage = createTestPackage('angular-base-package') + .factory('getImageDimensions', mockGetImageDimensions); + const dgeni = new Dgeni([testPackage]); + const injector = dgeni.configureInjector(); + log = injector.get('log'); + addImageDimensions = injector.get('addImageDimensions'); + addImageDimensions.basePath = 'base/path'; + getImageDimensionsSpy = injector.get('getImageDimensions'); + processor = injector.get('postProcessHtml'); + processor.docTypes = ['a']; + processor.plugins = [addImageDimensions]; + }); + + it('should add the image dimensions into tags', () => { + const docs = [{ + docType: 'a', + renderedContent: ` +

xxx

+ +

yyy

+ +

zzz

+ ` + }]; + processor.$process(docs); + expect(getImageDimensionsSpy).toHaveBeenCalledWith('base/path', 'a/b.jpg'); + expect(getImageDimensionsSpy).toHaveBeenCalledWith('base/path', 'c/d.png'); + expect(docs).toEqual([{ + docType: 'a', + renderedContent: ` +

xxx

+ +

yyy

+ +

zzz

+ ` + }]); + }); + + it('should log a warning for images with no src attribute', () => { + const docs = [{ + docType: 'a', + renderedContent: '' + }]; + processor.$process(docs); + expect(getImageDimensionsSpy).not.toHaveBeenCalled(); + expect(docs).toEqual([{ + docType: 'a', + renderedContent: '' + }]); + expect(log.warn).toHaveBeenCalled(); + }); + + it('should log a warning for images whose source cannot be loaded', () => { + getImageDimensionsSpy.and.callFake(() => { + const error = new Error('no such file or directory'); + error.code = 'ENOENT'; + throw error; + }); + const docs = [{ + docType: 'a', + renderedContent: '' + }]; + processor.$process(docs); + expect(getImageDimensionsSpy).toHaveBeenCalled(); + expect(docs).toEqual([{ + docType: 'a', + renderedContent: '' + }]); + expect(log.warn).toHaveBeenCalled(); + }); + + it('should ignore images with width or height attributes', () => { + const docs = [{ + docType: 'a', + renderedContent: ` + + + + ` + }]; + processor.$process(docs); + expect(getImageDimensionsSpy).not.toHaveBeenCalled(); + expect(docs).toEqual([{ + docType: 'a', + renderedContent: ` + + + + ` + }]); + }); + + function mockGetImageDimensions() { + const imageInfo = { + 'a/b.jpg': { width: 10, height: 20 }, + 'c/d.png': { width: 30, height: 40 }, + }; + // eslint-disable-next-line jasmine/no-unsafe-spy + return jasmine.createSpy('getImageDimensions') + .and.callFake((base, url) => imageInfo[url]); + } +}); diff --git a/aio/tools/transforms/angular-base-package/processors/copyContentAssets.js b/aio/tools/transforms/angular-base-package/processors/copyContentAssets.js index 1486a280b5..968a07a1c3 100644 --- a/aio/tools/transforms/angular-base-package/processors/copyContentAssets.js +++ b/aio/tools/transforms/angular-base-package/processors/copyContentAssets.js @@ -1,6 +1,7 @@ module.exports = function copyContentAssetsProcessor(copyFolder) { return { + $runBefore: ['postProcessHtml'], assetMappings: [], $process() { this.assetMappings.forEach(map => { diff --git a/aio/tools/transforms/angular-base-package/services/getImageDimensions.js b/aio/tools/transforms/angular-base-package/services/getImageDimensions.js new file mode 100644 index 0000000000..e32febb4c8 --- /dev/null +++ b/aio/tools/transforms/angular-base-package/services/getImageDimensions.js @@ -0,0 +1,6 @@ +const { resolve } = require('canonical-path'); +const sizeOf = require('image-size'); + +module.exports = function getImageDimensions() { + return (basePath, path) => sizeOf(resolve(basePath, path)); +}; diff --git a/aio/yarn.lock b/aio/yarn.lock index 14130e729d..59df42c6b4 100644 --- a/aio/yarn.lock +++ b/aio/yarn.lock @@ -3283,7 +3283,7 @@ ignorepatterns@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ignorepatterns/-/ignorepatterns-1.1.0.tgz#ac8f436f2239b5dfb66d5f0d3a904a87ac67cc5e" -image-size@~0.5.0: +image-size@^0.5.1, image-size@~0.5.0: version "0.5.1" resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.1.tgz#28eea8548a4b1443480ddddc1e083ae54652439f" @@ -7015,13 +7015,19 @@ unist-util-remove-position@^1.0.0: dependencies: unist-util-visit "^1.1.0" +unist-util-source@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/unist-util-source/-/unist-util-source-1.0.1.tgz#989fb50f8c8508d4cdd629162a38f06d7e08cc29" + dependencies: + vfile-location "^2.0.1" + unist-util-stringify-position@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.0.tgz#e8ba9d6b6af891b5f8336b3a31c63a9dc85c2af0" dependencies: has "^1.0.1" -unist-util-visit@^1.0.0, unist-util-visit@^1.1.0: +unist-util-visit@^1.0.0, unist-util-visit@^1.1.0, unist-util-visit@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.1.1.tgz#e917a3b137658b335cb4420c7da2e74d928e4e94" @@ -7218,7 +7224,7 @@ verror@1.3.6: dependencies: extsprintf "1.0.2" -vfile-location@^2.0.0: +vfile-location@^2.0.0, vfile-location@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.1.tgz#0bf8816f732b0f8bd902a56fda4c62c8e935dc52"