diff --git a/ghost/minifier/.eslintrc.js b/ghost/minifier/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/minifier/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/minifier/LICENSE b/ghost/minifier/LICENSE new file mode 100644 index 0000000000..366ae5f624 --- /dev/null +++ b/ghost/minifier/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2021 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/minifier/README.md b/ghost/minifier/README.md new file mode 100644 index 0000000000..1aa90cf176 --- /dev/null +++ b/ghost/minifier/README.md @@ -0,0 +1,56 @@ +# Minifier + +## Install + +`npm install @tryghost/minifier --save` + +or + +`yarn add @tryghost/minifier` + + +## Usage +``` +const Minifier = require('@tryghost/minifier'); +const minifier = new Minifier({ + src: 'my/src/path', + dest: 'my/dest/path' +}); + +minifier.minify({ + 'some.css': '*.css', + 'then.js': '!(other).js' +}); +``` + +- Minfier constructor requires a src and a dest +- minify() function takes an object with destination file as the key and source glob as the value + - globs can be anything tiny-glob supports + - destination files must end with .css or .js + - src files will be minified according to their destination file extension + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2013-2021 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/ghost/minifier/index.js b/ghost/minifier/index.js new file mode 100644 index 0000000000..770a69a08b --- /dev/null +++ b/ghost/minifier/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/minifier'); diff --git a/ghost/minifier/lib/minifier.js b/ghost/minifier/lib/minifier.js new file mode 100644 index 0000000000..f793bba22f --- /dev/null +++ b/ghost/minifier/lib/minifier.js @@ -0,0 +1,124 @@ +const errors = require('@tryghost/errors'); +const debug = require('@tryghost/debug')('affsfdfsdfsdfsdffsdsdfsd'); +const tpl = require('@tryghost/tpl'); +const csso = require('csso'); +const uglify = require('uglify-js'); +const glob = require('tiny-glob'); +const path = require('path'); +const fs = require('fs').promises; + +const messages = { + badDestination: { + message: 'Unexpected destination {dest}', + context: 'Minifier expected a destination that ended in .css or .js' + }, + missingConstructorOption: { + message: 'Minifier missing {opt} option', + context: 'new Minifier({}) requires a {opt} option' + }, + globalHelp: 'Refer to the readme for @tryghost/minifier for how to use this module' +}; + +// public API for minify hooks +class Minifier { + constructor({src, dest}) { + if (!src) { + throw new errors.IncorrectUsageError({ + message: tpl(messages.missingConstructorOption.message, {opt: 'src'}), + context: tpl(messages.missingConstructorOption.context, {opt: 'src'}), + help: tpl(messages.globalHelp) + }); + } + if (!dest) { + throw new errors.IncorrectUsageError({ + message: tpl(messages.missingConstructorOption.message, {opt: 'dest'}), + context: tpl(messages.missingConstructorOption.context, {opt: 'dest'}), + help: tpl(messages.globalHelp) + }); + } + this.srcPath = src; + this.destPath = dest; + } + + getFullSrc(src) { + return path.join(this.srcPath, src); + } + + getFullDest(dest) { + return path.join(this.destPath, dest); + } + + async minifyCSS(contents) { + const result = csso.minify(contents); + if (result && result.css) { + return result.css; + } + return null; + } + + async minifyJS(contents) { + const result = uglify.minify(contents); + if (result && result.code) { + return result.code; + } + + return null; + } + + async getMatchingFiles(src) { + return await glob(this.getFullSrc(src)); + } + + async readFiles(files) { + let mergedFiles = ''; + for (const file of files) { + const contents = await fs.readFile(file, 'utf8'); + mergedFiles += contents; + } + + return mergedFiles; + } + + async writeFile(contents, dest) { + if (contents) { + let writePath = this.getFullDest(dest); + await fs.writeFile(writePath, contents); + return writePath; + } + } + + async minify(options) { + debug('Begin', options); + const destinations = Object.keys(options); + const minifiedFiles = []; + + for (const dest of destinations) { + const src = options[dest]; + const files = await this.getMatchingFiles(src); + const contents = await this.readFiles(files); + let minifiedContents; + + if (dest.endsWith('.css')) { + minifiedContents = await this.minifyCSS(contents); + } else if (dest.endsWith('.js')) { + minifiedContents = await this.minifyJS(contents); + } else { + throw new errors.IncorrectUsageError({ + message: tpl(messages.badDestination.message, {dest}), + context: tpl(messages.badDestination.context), + help: tpl(messages.globalHelp) + }); + } + + const result = await this.writeFile(minifiedContents, dest); + if (result) { + minifiedFiles.push(result); + } + } + + debug('End'); + return minifiedFiles; + } +} + +module.exports = Minifier; diff --git a/ghost/minifier/package.json b/ghost/minifier/package.json new file mode 100644 index 0000000000..c0bf4876cc --- /dev/null +++ b/ghost/minifier/package.json @@ -0,0 +1,35 @@ +{ + "name": "@tryghost/minifier", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Utils/tree/main/packages/minifier", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing c8 --check-coverage mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "c8": "7.10.0", + "mocha": "9.1.2", + "should": "13.2.3", + "sinon": "11.1.2" + }, + "dependencies": { + "@tryghost/debug": "^0.1.8", + "@tryghost/errors": "^0.2.16", + "@tryghost/tpl": "^0.1.7", + "csso": "4.2.0", + "tiny-glob": "^0.2.9", + "uglify-js": "3.14.2" + } +} diff --git a/ghost/minifier/test/.eslintrc.js b/ghost/minifier/test/.eslintrc.js new file mode 100644 index 0000000000..836be78fdd --- /dev/null +++ b/ghost/minifier/test/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ], + ignorePatterns: ['fixtures'] +}; diff --git a/ghost/minifier/test/fixtures/basic-cards/css/bookmark.css b/ghost/minifier/test/fixtures/basic-cards/css/bookmark.css new file mode 100644 index 0000000000..35758bca69 --- /dev/null +++ b/ghost/minifier/test/fixtures/basic-cards/css/bookmark.css @@ -0,0 +1,83 @@ +/* style.css */ + +.kg-bookmark-card { + width: 100%; + position: relative; +} + +.kg-bookmark-container { + display: flex; + flex-wrap: wrap; + flex-direction: row-reverse; + color: currentColor; + font-family: inherit; + text-decoration: none; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.kg-bookmark-container:hover { + text-decoration: none; +} + +.kg-bookmark-content { + flex-basis: 0; + flex-grow: 999; + padding: 20px; + order: 1; +} + +.kg-bookmark-title { + font-weight: 600; +} + +.kg-bookmark-metadata, +.kg-bookmark-description { + margin-top: .5em; +} + +.kg-bookmark-metadata { + align-items: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.kg-bookmark-description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.kg-bookmark-icon { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: text-bottom; + margin-right: .5em; + margin-bottom: .05em; +} + +.kg-bookmark-thumbnail { + display: flex; + flex-basis: 24rem; + flex-grow: 1; +} + +.kg-bookmark-thumbnail img { + max-width: 100%; + height: auto; + vertical-align: bottom; + object-fit: cover; +} + +.kg-bookmark-author { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.kg-bookmark-publisher::before { + content: "•"; + margin: 0 .5em; +} diff --git a/ghost/minifier/test/fixtures/basic-cards/css/gallery.css b/ghost/minifier/test/fixtures/basic-cards/css/gallery.css new file mode 100644 index 0000000000..d571c9a5a8 --- /dev/null +++ b/ghost/minifier/test/fixtures/basic-cards/css/gallery.css @@ -0,0 +1,36 @@ +.kg-gallery-card { + margin: 0 0 1.5em; +} + +.kg-gallery-card figcaption { + margin: -1.0em 0 1.5em; +} + +.kg-gallery-container { + display: flex; + flex-direction: column; + margin: 1.5em auto; + max-width: 1040px; + width: 100vw; +} + +.kg-gallery-row { + display: flex; + flex-direction: row; + justify-content: center; +} + +.kg-gallery-image img { + display: block; + margin: 0; + width: 100%; + height: 100%; +} + +.kg-gallery-row:not(:first-of-type) { + margin: 0.75em 0 0 0; +} + +.kg-gallery-image:not(:first-of-type) { + margin: 0 0 0 0.75em; +} diff --git a/ghost/minifier/test/fixtures/basic-cards/js/gallery.js b/ghost/minifier/test/fixtures/basic-cards/js/gallery.js new file mode 100644 index 0000000000..1099f214c0 --- /dev/null +++ b/ghost/minifier/test/fixtures/basic-cards/js/gallery.js @@ -0,0 +1,8 @@ +var images = document.querySelectorAll('.kg-gallery-image img'); +images.forEach(function (image) { + var container = image.closest('.kg-gallery-image'); + var width = image.attributes.width.value; + var height = image.attributes.height.value; + var ratio = width / height; + container.style.flex = ratio + ' 1 0%'; +}) diff --git a/ghost/minifier/test/minifier.test.js b/ghost/minifier/test/minifier.test.js new file mode 100644 index 0000000000..eab5495244 --- /dev/null +++ b/ghost/minifier/test/minifier.test.js @@ -0,0 +1,72 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); +const path = require('path'); +const fs = require('fs').promises; +const os = require('os'); +const Minifier = require('../lib/minifier'); + +describe('Minifier', function () { + let minifier; + let testDir; + + before(async function () { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'minifier-tests-')); + + minifier = new Minifier({ + src: path.join(__dirname, 'fixtures', 'basic-cards'), + dest: path.join(os.tmpdir(), 'minifier-tests') + }); + }); + + after(async function () { + await fs.rmdir(testDir); + }); + + describe('getMatchingFiles expands globs correctly', function () { + it('star glob e.g. css/*.css', async function () { + let result = await minifier.getMatchingFiles('css/*.css'); + + result.should.be.an.Array().with.lengthOf(2); + result[0].should.eql('test/fixtures/basic-cards/css/bookmark.css'); + result[1].should.eql('test/fixtures/basic-cards/css/gallery.css'); + }); + + it('reverse match glob e.g. css/!(bookmark).css', async function () { + let result = await minifier.getMatchingFiles('css/!(bookmark).css'); + + result.should.be.an.Array().with.lengthOf(1); + result[0].should.eql('test/fixtures/basic-cards/css/gallery.css'); + }); + it('reverse match glob e.g. css/!(bookmark|gallery).css', async function () { + let result = await minifier.getMatchingFiles('css/!(bookmark|gallery).css'); + + result.should.be.an.Array().with.lengthOf(0); + }); + }); + + describe('Minify', function () { + it('single type, single file', async function () { + let result = await minifier.minify({ + 'card.min.js': 'js/*.js' + }); + result.should.be.an.Array().with.lengthOf(1); + }); + + it('single type, multi file', async function () { + let result = await minifier.minify({ + 'card.min.css': 'css/*.css' + }); + result.should.be.an.Array().with.lengthOf(1); + }); + + it('both css and js types + multiple files', async function () { + let result = await minifier.minify({ + 'card.min.js': 'js/*.js', + 'card.min.css': 'css/*.css' + }); + + result.should.be.an.Array().with.lengthOf(2); + }); + }); +}); diff --git a/ghost/minifier/test/utils/assertions.js b/ghost/minifier/test/utils/assertions.js new file mode 100644 index 0000000000..7364ee8aa1 --- /dev/null +++ b/ghost/minifier/test/utils/assertions.js @@ -0,0 +1,11 @@ +/** + * Custom Should Assertions + * + * Add any custom assertions to this file. + */ + +// Example Assertion +// should.Assertion.add('ExampleAssertion', function () { +// this.params = {operator: 'to be a valid Example Assertion'}; +// this.obj.should.be.an.Object; +// }); diff --git a/ghost/minifier/test/utils/index.js b/ghost/minifier/test/utils/index.js new file mode 100644 index 0000000000..0d67d86ff8 --- /dev/null +++ b/ghost/minifier/test/utils/index.js @@ -0,0 +1,11 @@ +/** + * Test Utilities + * + * Shared utils for writing tests + */ + +// Require overrides - these add globals for tests +require('./overrides'); + +// Require assertions - adds custom should assertions +require('./assertions'); diff --git a/ghost/minifier/test/utils/overrides.js b/ghost/minifier/test/utils/overrides.js new file mode 100644 index 0000000000..90203424ee --- /dev/null +++ b/ghost/minifier/test/utils/overrides.js @@ -0,0 +1,10 @@ +// This file is required before any test is run + +// Taken from the should wiki, this is how to make should global +// Should is a global in our eslint test config +global.should = require('should').noConflict(); +should.extend(); + +// Sinon is a simple case +// Sinon is a global in our eslint test config +global.sinon = require('sinon');