diff --git a/__snapshots__/hbs-minifier-plugin.test.js.snap b/__snapshots__/hbs-minifier-plugin.test.js.snap index fa8a386a..7622354b 100644 --- a/__snapshots__/hbs-minifier-plugin.test.js.snap +++ b/__snapshots__/hbs-minifier-plugin.test.js.snap @@ -239,6 +239,533 @@ Object { exports[`12. does not collapse   surrounding a text content into a single whitespace 2`] = `"
 1  2
"`; +exports[`13. does not minify \`tagNames\` specified in .hbs-minifyrc.js 1`] = ` +Object { + "blockParams": Array [], + "body": Array [ + Object { + "attributes": Array [], + "blockParams": Array [], + "children": Array [ + Object { + "chars": " + Box 564, + ", + "loc": Object { + "end": Object { + "column": 2, + "line": 3, + }, + "source": null, + "start": Object { + "column": 9, + "line": 1, + }, + }, + "type": "TextNode", + }, + Object { + "attributes": Array [], + "blockParams": Array [], + "children": Array [ + Object { + "chars": " + Disneyland + ", + "loc": Object { + "end": Object { + "column": 2, + "line": 5, + }, + "source": null, + "start": Object { + "column": 5, + "line": 3, + }, + }, + "type": "TextNode", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 6, + "line": 5, + }, + "source": null, + "start": Object { + "column": 2, + "line": 3, + }, + }, + "modifiers": Array [], + "tag": "b", + "type": "ElementNode", + }, + Object { + "chars": " + ", + "loc": Object { + "end": Object { + "column": 2, + "line": 6, + }, + "source": null, + "start": Object { + "column": 6, + "line": 5, + }, + }, + "type": "TextNode", + }, + Object { + "attributes": Array [], + "blockParams": Array [], + "children": Array [], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 6, + "line": 6, + }, + "source": null, + "start": Object { + "column": 2, + "line": 6, + }, + }, + "modifiers": Array [], + "tag": "br", + "type": "ElementNode", + }, + Object { + "chars": " + ", + "loc": Object { + "end": Object { + "column": 2, + "line": 7, + }, + "source": null, + "start": Object { + "column": 6, + "line": 6, + }, + }, + "type": "TextNode", + }, + Object { + "attributes": Array [], + "blockParams": Array [], + "children": Array [ + Object { + "chars": " USA ", + "loc": Object { + "end": Object { + "column": 10, + "line": 7, + }, + "source": null, + "start": Object { + "column": 5, + "line": 7, + }, + }, + "type": "TextNode", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 14, + "line": 7, + }, + "source": null, + "start": Object { + "column": 2, + "line": 7, + }, + }, + "modifiers": Array [], + "tag": "u", + "type": "ElementNode", + }, + Object { + "chars": " +", + "loc": Object { + "end": Object { + "column": 0, + "line": 8, + }, + "source": null, + "start": Object { + "column": 14, + "line": 7, + }, + }, + "type": "TextNode", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 10, + "line": 8, + }, + "source": null, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "modifiers": Array [], + "tag": "address", + "type": "ElementNode", + }, + ], + "loc": Object { + "end": Object { + "column": 10, + "line": 8, + }, + "source": null, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "type": "Program", +} +`; + +exports[`13. does not minify \`tagNames\` specified in .hbs-minifyrc.js 2`] = ` +"
+ Box 564, + + Disneyland + +

+ USA +
" +`; + +exports[`14. does not minify \`classNames\` specified in .hbs-minifyrc.js 1`] = ` +Object { + "blockParams": Array [], + "body": Array [ + Object { + "attributes": Array [ + Object { + "loc": Object { + "end": Object { + "column": 24, + "line": 1, + }, + "source": null, + "start": Object { + "column": 5, + "line": 1, + }, + }, + "name": "class", + "type": "AttrNode", + "value": Object { + "chars": "description", + "loc": Object { + "end": Object { + "column": 24, + "line": 1, + }, + "source": null, + "start": Object { + "column": 11, + "line": 1, + }, + }, + "type": "TextNode", + }, + }, + ], + "blockParams": Array [], + "children": Array [ + Object { + "chars": " + 1 + ", + "loc": Object { + "end": Object { + "column": 2, + "line": 3, + }, + "source": null, + "start": Object { + "column": 25, + "line": 1, + }, + }, + "type": "TextNode", + }, + Object { + "attributes": Array [], + "blockParams": Array [], + "children": Array [ + Object { + "chars": " + 2 + ", + "loc": Object { + "end": Object { + "column": 2, + "line": 5, + }, + "source": null, + "start": Object { + "column": 8, + "line": 3, + }, + }, + "type": "TextNode", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 9, + "line": 5, + }, + "source": null, + "start": Object { + "column": 2, + "line": 3, + }, + }, + "modifiers": Array [], + "tag": "span", + "type": "ElementNode", + }, + Object { + "chars": " +", + "loc": Object { + "end": Object { + "column": 0, + "line": 6, + }, + "source": null, + "start": Object { + "column": 9, + "line": 5, + }, + }, + "type": "TextNode", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 6, + "line": 6, + }, + "source": null, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "modifiers": Array [], + "tag": "div", + "type": "ElementNode", + }, + ], + "loc": Object { + "end": Object { + "column": 6, + "line": 6, + }, + "source": null, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "type": "Program", +} +`; + +exports[`14. does not minify \`classNames\` specified in .hbs-minifyrc.js 2`] = ` +"
+ 1 + + 2 + +
" +`; + +exports[`15. does not minify \`components\` specified in .hbs-minifyrc.js 1`] = ` +Object { + "blockParams": Array [], + "body": Array [ + Object { + "hash": Object { + "loc": Object { + "end": Object { + "column": 0, + "line": 1, + }, + "source": "(synthetic)", + "start": Object { + "column": 0, + "line": 1, + }, + }, + "pairs": Array [], + "type": "Hash", + }, + "inverse": null, + "loc": Object { + "end": Object { + "column": 12, + "line": 5, + }, + "source": null, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "params": Array [], + "path": Object { + "data": false, + "loc": SourceLocation { + "end": Object { + "column": 10, + "line": 1, + }, + "source": undefined, + "start": Object { + "column": 3, + "line": 1, + }, + }, + "original": "foo-bar", + "parts": Array [ + "foo-bar", + ], + "this": false, + "type": "PathExpression", + }, + "program": Object { + "blockParams": Array [], + "body": Array [ + Object { + "chars": " ", + "loc": Object { + "end": Object { + "column": 2, + "line": 2, + }, + "source": null, + "start": Object { + "column": 0, + "line": 2, + }, + }, + "type": "TextNode", + }, + Object { + "attributes": Array [], + "blockParams": Array [], + "children": Array [ + Object { + "chars": " + yield content + ", + "loc": Object { + "end": Object { + "column": 2, + "line": 4, + }, + "source": null, + "start": Object { + "column": 8, + "line": 2, + }, + }, + "type": "TextNode", + }, + ], + "comments": Array [], + "loc": Object { + "end": Object { + "column": 9, + "line": 4, + }, + "source": null, + "start": Object { + "column": 2, + "line": 2, + }, + }, + "modifiers": Array [], + "tag": "span", + "type": "ElementNode", + }, + Object { + "chars": " +", + "loc": Object { + "end": Object { + "column": 0, + "line": 5, + }, + "source": null, + "start": Object { + "column": 9, + "line": 4, + }, + }, + "type": "TextNode", + }, + ], + "loc": Object { + "end": Object { + "column": 0, + "line": 5, + }, + "source": null, + "start": Object { + "column": 12, + "line": 1, + }, + }, + "type": "Program", + }, + "type": "BlockStatement", + }, + ], + "loc": Object { + "end": Object { + "column": 12, + "line": 5, + }, + "source": null, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "type": "Program", +} +`; + +exports[`15. does not minify \`components\` specified in .hbs-minifyrc.js 2`] = ` +"{{#foo-bar}} + yield content + +{{/foo-bar}}" +`; + exports[`collapses whitespace into single space character 1`] = ` Object { "blockParams": Array [], diff --git a/ember-cli-build.js b/ember-cli-build.js index d8a05e78..284b12d8 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -7,6 +7,14 @@ const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); module.exports = function(defaults) { let app = new EmberAddon(defaults, { // Add options here + 'ember-hbs-minifier': { + whiteList: { + elementNodes: ['pre', 'address'], + elementClassNames: ['description'], + components: ['foo-bar'], + partials: [] + } + } }); /* diff --git a/hbs-minifier-plugin.js b/hbs-minifier-plugin.js index 3d4e390b..73861a97 100644 --- a/hbs-minifier-plugin.js +++ b/hbs-minifier-plugin.js @@ -6,10 +6,19 @@ const stripWhiteSpace = Util.stripWhiteSpace; const isWhitespaceTextNode = Util.isWhitespaceTextNode; const hasLeadingOrTrailingWhiteSpace = Util.hasLeadingOrTrailingWhiteSpace; const stripNoMinifyBlocks = Util.stripNoMinifyBlocks; +const canTrimBlockStatementContent = Util.canTrimBlockStatementContent; +const canTrimElementNodeContent = Util.canTrimElementNodeContent; +const canTrimModule = Util.canTrimModule; +const assignDefaultValues = Util.assignDefaultValues; + +class BasePlugin { + constructor(env) { + env = env || {}; + this.moduleName = env.moduleName; + } -class HBSMinifierPlugin { - - static createASTPlugin() { + static createASTPlugin(config) { + config = config || {}; let preStack = []; let visitor = { TextNode(node) { @@ -21,13 +30,14 @@ class HBSMinifierPlugin { BlockStatement: { enter(node) { - if (node.path.original === 'no-minify') { - preStack.push(true); + let canTrim = canTrimBlockStatementContent(node, config); + if (!canTrim) { + preStack.push(node); } }, exit(node) { - if (node.path.original === 'no-minify') { + if (preStack[preStack.length - 1] === node) { preStack.pop(); } }, @@ -57,8 +67,10 @@ class HBSMinifierPlugin { ElementNode: { enter(node) { - if (node.tag === 'pre') { - preStack.push(true); + let canTrim = canTrimElementNodeContent(node, config); + + if (!canTrim) { + preStack.push(node); } if (preStack.length !== 0) { @@ -79,7 +91,7 @@ class HBSMinifierPlugin { exit(node) { node.children = stripNoMinifyBlocks(node.children); - if (node.tag === 'pre') { + if (preStack[preStack.length - 1] === node) { preStack.pop(); } } @@ -88,15 +100,21 @@ class HBSMinifierPlugin { return { name: 'hbs-minifier-plugin', visitor }; } +} - transform(ast) { - let plugin = HBSMinifierPlugin.createASTPlugin(); - - this.syntax.traverse(ast, plugin.visitor); +module.exports = function(config) { + config = config || {}; + config = assignDefaultValues(config); - return ast; - } + return class HBSMinifierPlugin extends BasePlugin { -} + transform(ast) { -module.exports = HBSMinifierPlugin; + if (canTrimModule(this.moduleName, config)) { + let plugin = HBSMinifierPlugin.createASTPlugin(config); + this.syntax.traverse(ast, plugin.visitor); + } + return ast; + } + }; +}; diff --git a/hbs-minifier-plugin.test.js b/hbs-minifier-plugin.test.js index afb23da8..6c233b7f 100644 --- a/hbs-minifier-plugin.test.js +++ b/hbs-minifier-plugin.test.js @@ -1,9 +1,16 @@ 'use strict'; /* eslint-env jest */ - +const defaultConfig = { + whiteList: { + elementNodes: ['pre', 'address'], + elementClassNames: ['description'], + components: ['foo-bar'], + partials: [] + } +}; const glimmer = require('@glimmer/syntax'); -const HbsMinifierPlugin = require('./hbs-minifier-plugin'); +const HbsMinifierPlugin = require('./hbs-minifier-plugin')(defaultConfig); it('collapses whitespace into single space character', () => { assert(`{{foo}} \n\n \n{{bar}}`); @@ -56,6 +63,34 @@ it('12. does not collapse   surrounding a text content into a single whites `); }); +it('13. does not minify `tagNames` specified in .hbs-minifyrc.js', function() { + assert(`
+ Box 564, + + Disneyland + +
+ USA +
`); +}); + +it('14. does not minify `classNames` specified in .hbs-minifyrc.js', function() { + assert(`
+ 1 + + 2 + +
`); +}); + +it('15. does not minify `components` specified in .hbs-minifyrc.js', function() { + assert(`{{#foo-bar}} + + yield content + +{{/foo-bar}}`); +}); + function assert(template) { let ast = process(template); expect(ast).toMatchSnapshot(); @@ -65,9 +100,12 @@ function assert(template) { } function process(template) { + let plugin = () => { + return HbsMinifierPlugin.createASTPlugin(defaultConfig.whiteList); + }; return glimmer.preprocess(template, { plugins: { - ast: [HbsMinifierPlugin.createASTPlugin] + ast: [plugin] } }); } diff --git a/index.js b/index.js index 7ea876cb..58c9b0db 100644 --- a/index.js +++ b/index.js @@ -4,15 +4,28 @@ module.exports = { name: 'ember-hbs-minifier', - setupPreprocessorRegistry(type, registry) { - if (type === 'parent') { - let HbsMinifierPlugin = require('./hbs-minifier-plugin'); + _getMinifierOptions() { + return (this.parent && this.parent.options) || (this.app && this.app.options) || {}; + }, + + _setupPreprocessorRegistry(app) { + let registry = app.registry; + let options = this._getMinifierOptions(app); + let config = options['ember-hbs-minifier'] || {}; - registry.add('htmlbars-ast-plugin', { - name: 'hbs-minifier-plugin', - plugin: HbsMinifierPlugin, - baseDir() { return __dirname; } - }); - } + let HbsMinifierPlugin = require('./hbs-minifier-plugin')(config.whiteList || {}); + registry.add('htmlbars-ast-plugin', { + name: 'hbs-minifier-plugin', + plugin: HbsMinifierPlugin, + baseDir() { return __dirname; } + }); }, + included(app) { + this._super.included.apply(this, arguments); + /* + Calling setupPreprocessorRegistry in included hook since app.options is not accessible + Refer PR: https://github.com/ember-cli/ember-cli/pull/7059 + */ + this._setupPreprocessorRegistry(app); + } }; diff --git a/tests/dummy/app/components/foo-bar.js b/tests/dummy/app/components/foo-bar.js new file mode 100644 index 00000000..13dd7f1e --- /dev/null +++ b/tests/dummy/app/components/foo-bar.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; +import layout from '../templates/components/foo-bar'; + +export default Ember.Component.extend({ + layout +}); diff --git a/tests/dummy/app/templates/components/foo-bar.hbs b/tests/dummy/app/templates/components/foo-bar.hbs new file mode 100644 index 00000000..3994ab39 --- /dev/null +++ b/tests/dummy/app/templates/components/foo-bar.hbs @@ -0,0 +1,8 @@ +1 + + 2 + + + 3 + +{{yield}} diff --git a/tests/integration/components/dummy-component-test.js b/tests/integration/components/dummy-component-test.js index 7cf22adf..5201218b 100644 --- a/tests/integration/components/dummy-component-test.js +++ b/tests/integration/components/dummy-component-test.js @@ -149,4 +149,59 @@ describe('HBS Minifier plugin', function() { expect(childNode.textContent.trim()).to.equal('1'); }); + it('does not minify `tagNames` specified in .hbs-minifyrc.js', function() { + this.render(hbs ` +
+ Box 564, + + Disneyland + +
+ USA +
`); + + let childNodes = this.$('address')[0].childNodes; + expect(childNodes[0].textContent).to.equal('\n Box 564,\n '); + // ensuring the textContent is surrounded by whitespaces + expect(childNodes[1].textContent).to.equal('\n Disneyland\n '); + expect(childNodes[2].textContent).to.equal('\n '); + expect(childNodes[4].textContent).to.equal('\n '); + expect(childNodes[5].textContent).to.equal(' USA '); + }); + + + it('does not minify `classNames` specified in .hbs-minifyrc.js', function() { + this.render(hbs ` +
+ 1 + + 2 + +
`); + + let childNodes = this.$('div')[0].childNodes; + expect(childNodes[0].textContent).to.equal('\n 1\n '); + expect(childNodes[1].textContent).to.equal('\n 2\n '); + expect(childNodes[2].textContent).to.equal('\n'); + }); + + it('does not minify `components` specified in .hbs-minifyrc.js', function() { + this.render(hbs ` +{{#foo-bar}} + + yield content + +{{/foo-bar}}`); + + let childNodes = this.$('div')[0].childNodes; + expect(childNodes[0].textContent).to.equal('1\n'); + expect(childNodes[1].textContent).to.equal('\n 2\n'); + expect(childNodes[3].textContent).to.equal('\n 3\n'); + expect(childNodes[4].textContent).to.equal('\n'); + expect(childNodes[5].textContent).to.equal(' '); + expect(childNodes[6].textContent).to.equal('\n yield content\n '); + expect(childNodes[7].textContent).to.equal('\n'); + }); + + }); diff --git a/utils/helpers.js b/utils/helpers.js index 44e7b3ab..44a9662c 100644 --- a/utils/helpers.js +++ b/utils/helpers.js @@ -30,9 +30,148 @@ function stripNoMinifyBlocks(nodes) { }).reduce((a, b) => a.concat(b), []); } +function getElementAttribute(node, attrName) { + let attribute = node.attributes.find((attr) => { + return attr.type === 'AttrNode' && attr.name === attrName; + }); + return (attribute || {}).value; +} + +function isComponentIncluded(componentName, components) { + return components.indexOf(componentName) !== -1 || components === 'all'; +} + +function isPartialIncluded(templateName, partials) { + return partials.indexOf(templateName) !== -1 || partials === 'all'; +} + +function canTrimModule(modulePath, config) { + // If a component/partial is specified, then we must not minify the entire template file. + if (!modulePath) { + return true; + } + let path = modulePath.match(/templates\/.*(?=\.hbs)/)[0]; + + if (path.startsWith('templates/components/')) { + let componentName = path.replace('templates/components/', ''); + return !isComponentIncluded(componentName, config.components); + } else { + let templateName = path.replace('templates/', ''); + return !isPartialIncluded(templateName, config.partials); + } +} + +function isClassIncluded(chars, elementClassNames) { + chars = (chars || '').trim().split(' '); + + return chars.some((char) => { + return elementClassNames.indexOf(char) !== -1; + }); +} + +function canTrimUnnecessaryWhiteSpace(value, config) { + /* + 1. If no value is provided for class, the we can minify the content. + 2. If all classNames need to be preserved, then we must preserve the whitespace. + 3. If a string specified(class) contains a whitelist class, we must preserve the whitespace. + For instance: +
+ baz +
+ 4. If a PathExpression is provided as mentioned below, we should preserve the whitespace since the value is known only at runtime. + For instance: +
+ bar +
+ 5. If a MustacheStatement is provided as mentioned below. Incase if its a helper if/unless, we need to preserve if any whitelist class is specified which can be found by following steps 1 to 4. + For instance: +
+ qux +
+ 6. If a ConcatStatement is provided, for instance, +
+ bar +
+ we need to preserve the whitespace if any whitelist class is specified which can be found by following steps 1 to 4. + */ + if (!value) { + return true; + } + if (config.elementClassNames === 'all') { + return false; + } + let type = value.type; + + if (type === 'TextNode') { + return !isClassIncluded(value.chars, config.elementClassNames); + } else if (type === 'StringLiteral') { + return !isClassIncluded(value.value, config.elementClassNames); + } else if (type === 'PathExpression') { + return false; + } else if (type === 'MustacheStatement') { + let canTrim = true; + + if (['if', 'unless'].indexOf(value.path.original) !== -1) { + let params = value.params; + for (let i = 1; i < params.length; i++) { + canTrim = canTrimUnnecessaryWhiteSpace(params[i], config); + if (!canTrim) { + break; + } + } + } + return canTrim; + } else if (type === 'ConcatStatement') { + let parts = value.parts; + + return parts.every((part) => { + return canTrimUnnecessaryWhiteSpace(part, config); + }); + } + return true; +} + + +const canTrimBlockStatementContent = function(node, config) { + // If a block or all the blocks is/are whitelisted (or) named as 'no-minify' then we need to preserve the whitespace. + let componentName = node.path.original; + if (config.components.indexOf(componentName) !== -1 || componentName === 'no-minify' || config.components === 'all') { + return false; + } + return true; +}; + +const canTrimElementNodeContent = function(node, config) { + // If a element or all the element is/are whitelisted then we need to preserve the whitespace. + if (config.elementNodes.indexOf(node.tag) !== -1 || config.elementNodes === 'all') { + return false; + } + let classAttributes = getElementAttribute(node, 'class'); + return classAttributes ? canTrimUnnecessaryWhiteSpace(classAttributes, config) : true; +}; + +const assignDefaultValues = function(config) { + config = config || {}; + let elementNodes = config.elementNodes || 'all'; + let elementClassNames = config.elementClassNames || 'all'; + let components = config.components || 'all'; + let partials = config.partials || 'all'; + + return { + elementNodes, + elementClassNames, + components, + partials + }; +}; + module.exports = { stripWhiteSpace, isWhitespaceTextNode, stripNoMinifyBlocks, - hasLeadingOrTrailingWhiteSpace + hasLeadingOrTrailingWhiteSpace, + canTrimBlockStatementContent, + canTrimElementNodeContent, + canTrimModule, + assignDefaultValues };