diff --git a/core/server/config/index.js b/core/server/config/index.js index 68a10065a8..6ff1f56b49 100644 --- a/core/server/config/index.js +++ b/core/server/config/index.js @@ -10,6 +10,7 @@ var path = require('path'), knex = require('knex'), validator = require('validator'), readDirectory = require('../utils/read-directory'), + readThemes = require('../utils/read-themes'), errors = require('../errors'), configUrl = require('./url'), packageInfo = require('../../../package.json'), @@ -75,7 +76,7 @@ ConfigManager.prototype.init = function (rawConfig) { // just the object appropriate for this NODE_ENV self.set(rawConfig); - return Promise.all([readDirectory(self._config.paths.themePath), readDirectory(self._config.paths.appPath)]).then(function (paths) { + return Promise.all([readThemes(self._config.paths.themePath), readDirectory(self._config.paths.appPath)]).then(function (paths) { self._config.paths.availableThemes = paths[0]; self._config.paths.availableApps = paths[1]; return self._config; diff --git a/core/server/data/validation/index.js b/core/server/data/validation/index.js index 97115c4b62..9aaf29e130 100644 --- a/core/server/data/validation/index.js +++ b/core/server/data/validation/index.js @@ -4,7 +4,7 @@ var schema = require('../schema').tables, Promise = require('bluebird'), errors = require('../../errors'), config = require('../../config'), - readDirectory = require('../../utils/read-directory'), + readThemes = require('../../utils/read-themes'), validateSchema, validateSettings, @@ -112,7 +112,7 @@ validateActiveTheme = function validateActiveTheme(themeName) { // A Promise that will resolve to an object with a property for each installed theme. // This is necessary because certain configuration data is only available while Ghost // is running and at times the validations are used when it's not (e.g. tests) - availableThemes = readDirectory(config.paths.themePath); + availableThemes = readThemes(config.paths.themePath); } return availableThemes.then(function then(themes) { diff --git a/core/server/utils/read-directory.js b/core/server/utils/read-directory.js index 415e834c48..4ead7ed134 100644 --- a/core/server/utils/read-directory.js +++ b/core/server/utils/read-directory.js @@ -22,7 +22,7 @@ function readDirectory(dir, options) { } ignore = options.ignore || []; - ignore.push('node_modules', 'bower_components'); + ignore.push('node_modules', 'bower_components', '.DS_Store'); return readDir(dir) .filter(function (filename) { diff --git a/core/server/utils/read-themes.js b/core/server/utils/read-themes.js new file mode 100644 index 0000000000..295d16ba88 --- /dev/null +++ b/core/server/utils/read-themes.js @@ -0,0 +1,46 @@ +/** + * Dependencies + */ + +var readDirectory = require('./read-directory'), + Promise = require('bluebird'), + join = require('path').join, + fs = require('fs'), + + statFile = Promise.promisify(fs.stat); + +/** + * Read themes + */ + +function readThemes(dir) { + var originalTree; + + return readDirectory(dir) + .tap(function (tree) { + originalTree = tree; + }) + .then(Object.keys) + .filter(function (file) { + var path = join(dir, file); + + return statFile(path).then(function (stat) { + return stat.isDirectory(); + }); + }) + .then(function (directories) { + var themes = {}; + + directories.forEach(function (name) { + themes[name] = originalTree[name]; + }); + + return themes; + }); +} + +/** + * Expose `read-themes` + */ + +module.exports = readThemes; diff --git a/core/server/utils/validate-themes.js b/core/server/utils/validate-themes.js index 72644d18fd..1833567e60 100644 --- a/core/server/utils/validate-themes.js +++ b/core/server/utils/validate-themes.js @@ -2,7 +2,7 @@ * Dependencies */ -var readDirectory = require('./read-directory'), +var readThemes = require('./read-themes'), Promise = require('bluebird'), _ = require('lodash'); @@ -18,7 +18,7 @@ function validateThemes(dir) { errors: [] }; - return readDirectory(dir) + return readThemes(dir) .tap(function (themes) { _.each(themes, function (theme, name) { var hasPackageJson, warning; diff --git a/core/test/unit/server_utils_spec.js b/core/test/unit/server_utils_spec.js index 8c0c3a44dc..8ed6d3e956 100644 --- a/core/test/unit/server_utils_spec.js +++ b/core/test/unit/server_utils_spec.js @@ -1,81 +1,352 @@ /*globals describe, it*/ /*jshint expr:true*/ var should = require('should'), - utils = require('../../server/utils'); + parsePackageJson = require('../../server/utils/parse-package-json'), + validateThemes = require('../../server/utils/validate-themes'), + readDirectory = require('../../server/utils/read-directory'), + readThemes = require('../../server/utils/read-themes'), + tempfile = require('../utils/tempfile'), + utils = require('../../server/utils'), + join = require('path').join, + fs = require('fs'); // To stop jshint complaining should.equal(true, true); -describe('Safe String', function () { - var safeString = utils.safeString, - options = {}; +describe('Server Utilities', function () { + describe('Safe String', function () { + var safeString = utils.safeString, + options = {}; - it('should remove beginning and ending whitespace', function () { - var result = safeString(' stringwithspace ', options); - result.should.equal('stringwithspace'); + it('should remove beginning and ending whitespace', function () { + var result = safeString(' stringwithspace ', options); + result.should.equal('stringwithspace'); + }); + + it('should remove non ascii characters', function () { + var result = safeString('howtowin✓', options); + result.should.equal('howtowin'); + }); + + it('should replace spaces with dashes', function () { + var result = safeString('how to win', options); + result.should.equal('how-to-win'); + }); + + it('should replace most special characters with dashes', function () { + var result = safeString('a:b/c?d#e[f]g!h$i&j(k)l*m+n,o;p=q\\r%su|v^w~x£y"z@1.2', options); + result.should.equal('a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-1-2'); + }); + + it('should remove special characters at the beginning of a string', function () { + var result = safeString('.Not special', options); + result.should.equal('not-special'); + }); + + it('should remove apostrophes ', function () { + var result = safeString('how we shouldn\'t be', options); + result.should.equal('how-we-shouldnt-be'); + }); + + it('should convert to lowercase', function () { + var result = safeString('This has Upper Case', options); + result.should.equal('this-has-upper-case'); + }); + + it('should convert multiple dashes into a single dash', function () { + var result = safeString('This :) means everything', options); + result.should.equal('this-means-everything'); + }); + + it('should remove trailing dashes from the result', function () { + var result = safeString('This.', options); + result.should.equal('this'); + }); + + it('should handle pound signs', function () { + var result = safeString('WHOOPS! I spent all my £ again!', options); + result.should.equal('whoops-i-spent-all-my-again'); + }); + + it('should properly handle unicode punctuation conversion', function () { + var result = safeString('に間違いがないか、再度確認してください。再読み込みしてください。', options); + result.should.equal('nijian-wei-iganaika-zai-du-que-ren-sitekudasai-zai-du-miip-misitekudasai'); + }); + + it('should not lose or convert dashes if options are passed with truthy importing flag', function () { + var result, + options = {importing: true}; + result = safeString('-slug-with-starting-ending-and---multiple-dashes-', options); + result.should.equal('-slug-with-starting-ending-and---multiple-dashes-'); + }); + + it('should still remove/convert invalid characters when passed options with truthy importing flag', function () { + var result, + options = {importing: true}; + result = safeString('-slug-&with-✓-invalid-characters-に\'', options); + result.should.equal('-slug--with--invalid-characters-ni'); + }); }); - it('should remove non ascii characters', function () { - var result = safeString('howtowin✓', options); - result.should.equal('howtowin'); + describe('parse-package-json', function () { + it('should parse valid package.json', function (done) { + var pkgJson, tmpPath; + + tmpPath = tempfile(); + pkgJson = JSON.stringify({ + name: 'test', + version: '0.0.0' + }); + + fs.writeFileSync(tmpPath, pkgJson); + + parsePackageJson(tmpPath) + .then(function (pkg) { + pkg.should.eql({ + name: 'test', + version: '0.0.0' + }); + + done(); + }) + .catch(done); + }); + + it('should fail when name is missing', function (done) { + var pkgJson, tmpPath; + + tmpPath = tempfile(); + pkgJson = JSON.stringify({ + version: '0.0.0' + }); + + fs.writeFileSync(tmpPath, pkgJson); + + parsePackageJson(tmpPath) + .then(function () { + done(new Error('parsePackageJson succeeded, but should\'ve failed')); + }) + .catch(function (err) { + err.message.should.equal('"name" or "version" is missing from theme package.json file.'); + err.context.should.equal(tmpPath); + err.help.should.equal('This will be required in future. Please see http://docs.ghost.org/themes/'); + + done(); + }); + }); + + it('should fail when version is missing', function (done) { + var pkgJson, tmpPath; + + tmpPath = tempfile(); + pkgJson = JSON.stringify({ + name: 'test' + }); + + fs.writeFileSync(tmpPath, pkgJson); + + parsePackageJson(tmpPath) + .then(function () { + done(new Error('parsePackageJson succeeded, but should\'ve failed')); + }) + .catch(function (err) { + err.message.should.equal('"name" or "version" is missing from theme package.json file.'); + err.context.should.equal(tmpPath); + err.help.should.equal('This will be required in future. Please see http://docs.ghost.org/themes/'); + + done(); + }); + }); + + it('should fail when JSON is invalid', function (done) { + var pkgJson, tmpPath; + + tmpPath = tempfile(); + pkgJson = '{name:"test"}'; + + fs.writeFileSync(tmpPath, pkgJson); + + parsePackageJson(tmpPath) + .then(function () { + done(new Error('parsePackageJson succeeded, but should\'ve failed')); + }) + .catch(function (err) { + err.message.should.equal('Theme package.json file is malformed'); + err.context.should.equal(tmpPath); + err.help.should.equal('This will be required in future. Please see http://docs.ghost.org/themes/'); + + done(); + }); + }); + + it('should fail when file is missing', function (done) { + var tmpPath = tempfile(); + + parsePackageJson(tmpPath) + .then(function () { + done(new Error('parsePackageJson succeeded, but should\'ve failed')); + }) + .catch(function (err) { + err.message.should.equal('Could not read package.json file'); + err.context.should.equal(tmpPath); + + done(); + }); + }); }); - it('should replace spaces with dashes', function () { - var result = safeString('how to win', options); - result.should.equal('how-to-win'); + describe('read-directory', function () { + it('should read directory recursively', function (done) { + var themePath = tempfile(); + + // create example theme + fs.mkdirSync(themePath); + fs.mkdirSync(join(themePath, 'partials')); + fs.writeFileSync(join(themePath, 'index.hbs')); + fs.writeFileSync(join(themePath, 'partials', 'navigation.hbs')); + + readDirectory(themePath) + .then(function (tree) { + tree.should.eql({ + partials: { + 'navigation.hbs': join(themePath, 'partials', 'navigation.hbs') + }, + 'index.hbs': join(themePath, 'index.hbs') + }); + + done(); + }) + .catch(done); + }); + + it('should read directory and ignore unneeded items', function (done) { + var themePath = tempfile(); + + // create example theme + fs.mkdirSync(themePath); + fs.mkdirSync(join(themePath, 'partials')); + fs.writeFileSync(join(themePath, 'index.hbs')); + fs.writeFileSync(join(themePath, 'partials', 'navigation.hbs')); + + // create some trash + fs.mkdirSync(join(themePath, 'node_modules')); + fs.mkdirSync(join(themePath, 'bower_components')); + fs.mkdirSync(join(themePath, '.git')); + fs.writeFileSync(join(themePath, '.DS_Store')); + + readDirectory(themePath, {ignore: ['.git']}) + .then(function (tree) { + tree.should.eql({ + partials: { + 'navigation.hbs': join(themePath, 'partials', 'navigation.hbs') + }, + 'index.hbs': join(themePath, 'index.hbs') + }); + + done(); + }) + .catch(done); + }); + + it('should read directory and parse package.json files', function (done) { + var themePath, pkgJson; + + themePath = tempfile(); + pkgJson = JSON.stringify({ + name: 'test', + version: '0.0.0' + }); + + // create example theme + fs.mkdirSync(themePath); + fs.mkdirSync(join(themePath, 'partials')); + fs.writeFileSync(join(themePath, 'package.json'), pkgJson); + fs.writeFileSync(join(themePath, 'index.hbs')); + fs.writeFileSync(join(themePath, 'partials', 'navigation.hbs')); + + readDirectory(themePath) + .then(function (tree) { + tree.should.eql({ + partials: { + 'navigation.hbs': join(themePath, 'partials', 'navigation.hbs') + }, + 'index.hbs': join(themePath, 'index.hbs'), + 'package.json': { + name: 'test', + version: '0.0.0' + } + }); + + done(); + }) + .catch(done); + }); }); - it('should replace most special characters with dashes', function () { - var result = safeString('a:b/c?d#e[f]g!h$i&j(k)l*m+n,o;p=q\\r%su|v^w~x£y"z@1.2', options); - result.should.equal('a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-1-2'); + describe('read-themes', function () { + it('should read directory and include only folders', function (done) { + var themesPath = tempfile(); + + fs.mkdirSync(themesPath); + + // create trash + fs.writeFileSync(join(themesPath, 'casper.zip')); + fs.writeFileSync(join(themesPath, '.DS_Store')); + + // create actual theme + fs.mkdirSync(join(themesPath, 'casper')); + fs.mkdirSync(join(themesPath, 'casper', 'partials')); + fs.writeFileSync(join(themesPath, 'casper', 'index.hbs')); + fs.writeFileSync(join(themesPath, 'casper', 'partials', 'navigation.hbs')); + + readThemes(themesPath) + .then(function (tree) { + tree.should.eql({ + casper: { + partials: { + 'navigation.hbs': join(themesPath, 'casper', 'partials', 'navigation.hbs') + }, + 'index.hbs': join(themesPath, 'casper', 'index.hbs') + } + }); + + done(); + }) + .catch(done); + }); }); - it('should remove special characters at the beginning of a string', function () { - var result = safeString('.Not special', options); - result.should.equal('not-special'); - }); + describe('validate-themes', function () { + it('should return warnings for themes without package.json', function (done) { + var themesPath, pkgJson; - it('should remove apostrophes ', function () { - var result = safeString('how we shouldn\'t be', options); - result.should.equal('how-we-shouldnt-be'); - }); + themesPath = tempfile(); + pkgJson = JSON.stringify({ + name: 'casper', + version: '1.0.0' + }); - it('should convert to lowercase', function () { - var result = safeString('This has Upper Case', options); - result.should.equal('this-has-upper-case'); - }); + fs.mkdirSync(themesPath); - it('should convert multiple dashes into a single dash', function () { - var result = safeString('This :) means everything', options); - result.should.equal('this-means-everything'); - }); + fs.mkdirSync(join(themesPath, 'casper')); + fs.mkdirSync(join(themesPath, 'invalid-casper')); - it('should remove trailing dashes from the result', function () { - var result = safeString('This.', options); - result.should.equal('this'); - }); + fs.writeFileSync(join(themesPath, 'casper', 'package.json'), pkgJson); - it('should handle pound signs', function () { - var result = safeString('WHOOPS! I spent all my £ again!', options); - result.should.equal('whoops-i-spent-all-my-again'); - }); + validateThemes(themesPath) + .then(function () { + done(new Error('validateThemes succeeded, but should\'ve failed')); + }) + .catch(function (result) { + result.errors.length.should.equal(0); + result.warnings.should.eql([{ + message: 'Found a theme with no package.json file', + context: 'Theme name: invalid-casper', + help: 'This will be required in future. Please see http://docs.ghost.org/themes/' + }]); - it('should properly handle unicode punctuation conversion', function () { - var result = safeString('に間違いがないか、再度確認してください。再読み込みしてください。', options); - result.should.equal('nijian-wei-iganaika-zai-du-que-ren-sitekudasai-zai-du-miip-misitekudasai'); - }); - - it('should not lose or convert dashes if options are passed with truthy importing flag', function () { - var result, - options = {importing: true}; - result = safeString('-slug-with-starting-ending-and---multiple-dashes-', options); - result.should.equal('-slug-with-starting-ending-and---multiple-dashes-'); - }); - - it('should still remove/convert invalid characters when passed options with truthy importing flag', function () { - var result, - options = {importing: true}; - result = safeString('-slug-&with-✓-invalid-characters-に\'', options); - result.should.equal('-slug--with--invalid-characters-ni'); + done(); + }); + }); }); }); diff --git a/core/test/utils/tempfile.js b/core/test/utils/tempfile.js new file mode 100644 index 0000000000..3f89d47c25 --- /dev/null +++ b/core/test/utils/tempfile.js @@ -0,0 +1,23 @@ +/** + * Dependencies + */ + +var join = require('path').join, + + TMP_DIR = require('os').tmpdir(); + +/** + * Generate a temporary file path + */ + +function tempfile() { + var randomString = Math.random().toString(36).substring(7); + + return join(TMP_DIR, randomString); +} + +/** + * Expose `tempfile` + */ + +module.exports = tempfile;