diff --git a/index.js b/index.js index cf8d162..9e1ae73 100644 --- a/index.js +++ b/index.js @@ -111,10 +111,14 @@ var addInstructionsToBrowserify = require('./lib/bundler'); * of `{appRootDir}/middleware.json` * @property {Object} [components] Component configuration to use instead * of `{appRootDir}/component-config.json` + * @property {Array.} [mixinDirs] List of directories where to look + * for files containing model mixin definitions. * @property {Array.} [bootDirs] List of directories where to look * for boot scripts. * @property {Array.} [bootScripts] List of script files to execute * on boot. + * @property {String|Function|Boolean} [normalization] Mixin normalization + * format: false, 'none', 'classify', 'dasherize' - defaults to 'classify'. * @end * @param {Function} [callback] Callback function. * diff --git a/lib/bundler.js b/lib/bundler.js index a1a8a1c..52e0736 100644 --- a/lib/bundler.js +++ b/lib/bundler.js @@ -11,6 +11,7 @@ var cloneDeep = require('lodash').cloneDeep; module.exports = function addInstructionsToBrowserify(instructions, bundler) { bundleModelScripts(instructions, bundler); + bundleMixinScripts(instructions, bundler); bundleComponentScripts(instructions, bundler); bundleOtherScripts(instructions, bundler); bundleInstructions(instructions, bundler); @@ -26,6 +27,10 @@ function bundleModelScripts(instructions, bundler) { bundleSourceFiles(instructions, 'models', bundler); } +function bundleMixinScripts(instructions, bundler) { + bundleSourceFiles(instructions, 'mixins', bundler); +} + function bundleComponentScripts(instructions, bundler) { bundleSourceFiles(instructions, 'components', bundler); } diff --git a/lib/compiler.js b/lib/compiler.js index f526a56..70459ab 100644 --- a/lib/compiler.js +++ b/lib/compiler.js @@ -84,6 +84,10 @@ module.exports = function compile(options) { var modelInstructions = buildAllModelInstructions( modelsRootDir, modelsConfig, modelSources); + var mixinDirs = options.mixinDirs || []; + var mixinInstructions = buildAllMixinInstructions( + appRootDir, mixinDirs, options); + // When executor passes the instruction to loopback methods, // loopback modifies the data. Since we are loading the data using `require`, // such change affects also code that calls `require` for the same file. @@ -93,6 +97,7 @@ module.exports = function compile(options) { models: modelInstructions, middleware: middlewareInstructions, components: componentInstructions, + mixins: mixinInstructions, files: { boot: bootScripts } @@ -135,10 +140,11 @@ function assertIsValidModelConfig(config) { * @private */ -function findScripts(dir) { +function findScripts(dir, extensions) { assert(dir, 'cannot require directory contents without directory name'); var files = tryReadDir(dir); + extensions = extensions || _.keys(require.extensions); // sort files in lowercase alpha for linux files.sort(function(a, b) { @@ -599,3 +605,69 @@ function resolveAppScriptPath(rootDir, relativePath, resolveOptions) { var fixedFile = fixFileExtension(resolvedPath, files, false); return (fixedFile === undefined ? resolvedPath : fixedFile); } + +function buildAllMixinInstructions(appRootDir, mixinDirs, options) { + var extensions = _.without(_.keys(require.extensions), + _.keys(getExcludedExtensions())); + var files = options.mixins || []; + + mixinDirs.forEach(function(dir) { + dir = tryResolveAppPath(appRootDir, dir); + if (!dir) { + debug('Skipping unknown module source dir %j', dir); + return; + } + files = files.concat(findScripts(dir, extensions)); + }); + + var mixins = files.map(function(filepath) { + var dir = path.dirname(filepath); + var ext = path.extname(filepath); + var name = path.basename(filepath, ext); + var metafile = path.join(dir, name + FILE_EXTENSION_JSON); + + name = normalizeMixinName(name, options); + var meta = {}; + meta.name = name; + if (fs.existsSync(metafile)) { + // May overwrite name, not sourceFile + _.extend(meta, require(metafile)); + } + meta.sourceFile = filepath; + return meta; + }); + + return mixins; +} + +function normalizeMixinName(str, options) { + var normalization = options.normalization; + switch (normalization) { + case false: + case 'none': return str; + + case undefined: + case 'classify': + str = String(str).replace(/([A-Z]+)/g, ' $1').trim(); + str = String(str).replace(/[\W_]/g, ' ').toLowerCase(); + str = str.replace(/(?:^|\s|-)\S/g, function(c) { return c.toUpperCase(); }); + str = str.replace(/\s+/g, ''); + return str; + + case 'dasherize': + str = String(str).replace(/([A-Z]+)/g, ' $1').trim(); + str = String(str).replace(/[\W_]/g, ' ').toLowerCase(); + str = str.replace(/\s+/g, '-'); + return str; + + default: + if (typeof normalization === 'function') { + return normalization(str); + } + + var err = new Error('Invalid normalization format - "' + + normalization + '"'); + err.code = 'INVALID_NORMALIZATION_FORMAT'; + throw err; + } +} diff --git a/lib/executor.js b/lib/executor.js index de77a74..5198d7f 100644 --- a/lib/executor.js +++ b/lib/executor.js @@ -160,6 +160,7 @@ function setupDataSources(app, instructions) { } function setupModels(app, instructions) { + defineMixins(app, instructions); defineModels(app, instructions); instructions.models.forEach(function(data) { @@ -170,6 +171,26 @@ function setupModels(app, instructions) { }); } +function defineMixins(app, instructions) { + var modelBuilder = (app.registry || app.loopback).modelBuilder; + var BaseClass = app.loopback.Model; + var mixins = instructions.mixins || []; + + if (!modelBuilder.mixins || !mixins.length) return; + + mixins.forEach(function(obj) { + var mixin = require(obj.sourceFile); + + if (typeof mixin === 'function' || mixin.prototype instanceof BaseClass) { + debug('Defining mixin %s', obj.name); + modelBuilder.mixins.define(obj.name, mixin); // TODO (name, mixin, meta) + } else { + debug('Skipping mixin file %s - `module.exports` is not a function' + + ' or Loopback model', obj); + } + }); +} + function defineModels(app, instructions) { var registry = app.registry || app.loopback; instructions.models.forEach(function(data) { diff --git a/package.json b/package.json index b0429a6..fb9951c 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "fs-extra": "^0.12.0", "jscs": "^1.7.3", "jshint": "^2.5.6", - "loopback": "^2.5.0", + "loopback": "^2.16.3", "mocha": "^1.19.0", "supertest": "^0.14.0" } diff --git a/test/browser.test.js b/test/browser.test.js index 19dd334..8a92f5a 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -61,6 +61,27 @@ describe('browser support', function() { }); }); + it('loads mixins', function(done) { + var appDir = path.resolve(__dirname, './fixtures/browser-app'); + var options = { + appRootDir: appDir, + mixinDirs: ['./mixins'] + }; + + browserifyTestApp(options, function(err, bundlePath) { + if (err) return done(err); + + var app = executeBundledApp(bundlePath); + + var modelBuilder = app.registry.modelBuilder; + var registry = modelBuilder.mixins.mixins; + expect(Object.keys(registry)).to.eql(['TimeStamps']); + expect(app.models.Customer.timeStampsMixin).to.eql(true); + + done(); + }); + }); + it('supports coffee-script files', function(done) { // add coffee-script to require.extensions require('coffee-script/register'); @@ -82,7 +103,7 @@ describe('browser support', function() { }); }); -function browserifyTestApp(appDir, strategy, next) { +function browserifyTestApp(options, strategy, next) { // set default args if (((typeof strategy) === 'function') && !next) { next = strategy; @@ -91,9 +112,10 @@ function browserifyTestApp(appDir, strategy, next) { if (!strategy) strategy = 'default'; + var appDir = typeof(options) === 'object' ? options.appRootDir : options; var b = compileStrategies[strategy](appDir); - boot.compileToBrowserify(appDir, b); + boot.compileToBrowserify(options, b); exportBrowserifyToFile(b, 'browser-app-bundle.js', next); } diff --git a/test/compiler.test.js b/test/compiler.test.js index 6df170f..9ed42e3 100644 --- a/test/compiler.test.js +++ b/test/compiler.test.js @@ -1014,6 +1014,160 @@ describe('compiler', function() { instructions = boot.compile(appdir.PATH); expect(instructions.config).to.not.have.property('modified'); }); + + describe('for mixins', function() { + function verifyMixinIsFoundViaMixinDirs(sourceFile, mixinDirs) { + var appJS = appdir.writeFileSync(sourceFile, ''); + + var instructions = boot.compile({ + appRootDir: appdir.PATH, + mixinDirs: mixinDirs + }); + + expect(instructions.mixins[0].sourceFile).to.eql(appJS); + } + + it('supports `mixinDirs` option', function() { + verifyMixinIsFoundViaMixinDirs('mixins/other.js', ['./mixins']); + }); + + it('resolves relative path in `mixinDirs` option', function() { + verifyMixinIsFoundViaMixinDirs('custom-mixins/vehicle.js', + ['./custom-mixins']); + }); + + it('resolves module relative path in `mixinDirs` option', function() { + verifyMixinIsFoundViaMixinDirs('node_modules/custom-mixins/vehicle.js', + ['custom-mixins']); + }); + + describe('name normalization', function() { + var options; + beforeEach(function() { + options = { appRootDir: appdir.PATH, mixinDirs: ['./mixins'] }; + + appdir.writeFileSync('mixins/foo.js', ''); + appdir.writeFileSync('mixins/time-stamps.js', ''); + appdir.writeFileSync('mixins/camelCase.js', ''); + appdir.writeFileSync('mixins/PascalCase.js', ''); + appdir.writeFileSync('mixins/space name.js', ''); + }); + + it('supports classify', function() { + options.normalization = 'classify'; + var instructions = boot.compile(options); + + var mixins = instructions.mixins; + var mixinNames = mixins.map(getNameProperty); + + expect(mixinNames).to.eql([ + 'CamelCase', 'Foo', 'PascalCase', 'SpaceName', 'TimeStamps' + ]); + }); + + it('supports dasherize', function() { + options.normalization = 'dasherize'; + var instructions = boot.compile(options); + + var mixins = instructions.mixins; + var mixinNames = mixins.map(getNameProperty); + + expect(mixinNames).to.eql([ + 'camel-case', 'foo', 'pascal-case', 'space-name', 'time-stamps' + ]); + }); + + it('supports custom function', function() { + var normalize = function(name) { return name.toUpperCase(); }; + options.normalization = normalize; + var instructions = boot.compile(options); + + var mixins = instructions.mixins; + var mixinNames = mixins.map(getNameProperty); + + expect(mixinNames).to.eql([ + 'CAMELCASE', 'FOO', 'PASCALCASE', 'SPACE NAME', 'TIME-STAMPS' + ]); + }); + + it('supports none', function() { + options.normalization = 'none'; + var instructions = boot.compile(options); + + var mixins = instructions.mixins; + var mixinNames = mixins.map(getNameProperty); + + expect(mixinNames).to.eql([ + 'camelCase', 'foo', 'PascalCase', 'space name', 'time-stamps' + ]); + }); + + it('supports false', function() { + options.normalization = false; + var instructions = boot.compile(options); + + var mixins = instructions.mixins; + var mixinNames = mixins.map(getNameProperty); + + expect(mixinNames).to.eql([ + 'camelCase', 'foo', 'PascalCase', 'space name', 'time-stamps' + ]); + }); + + it('defaults to classify', function() { + var instructions = boot.compile(options); + + var mixins = instructions.mixins; + var mixinNames = mixins.map(getNameProperty); + + expect(mixinNames).to.eql([ + 'CamelCase', 'Foo', 'PascalCase', 'SpaceName', 'TimeStamps' + ]); + }); + + it('throws error for invalid normalization format', function() { + options.normalization = 'invalidFormat'; + + expect(function() { boot.compile(options); }) + .to.throw(/Invalid normalization format - "invalidFormat"/); + }); + }); + + it('overrides default mixin name, by `name` in JSON', function() { + appdir.writeFileSync('mixins/foo.js', ''); + appdir.writeConfigFileSync('mixins/foo.json', {name: 'fooBar'}); + + var options = { appRootDir: appdir.PATH, + mixinDirs: ['./mixins'] + }; + var instructions = boot.compile(options); + + expect(instructions.mixins[0].name).to.eql('fooBar'); + }); + + it('extends definition from JSON with same file name', function() { + var appJS = appdir.writeFileSync('mixins/foo-bar.js', ''); + + appdir.writeConfigFileSync('mixins/foo-bar.json', { + description: 'JSON file name same as JS file name' }); + appdir.writeConfigFileSync('mixins/FooBar.json', { + description: 'JSON file name same as normalized name of mixin' }); + + var options = { appRootDir: appdir.PATH, + mixinDirs: ['./mixins'], + normalization: 'classify' }; + var instructions = boot.compile(options); + + expect(instructions.mixins).to.eql([ + { + name: 'FooBar', + description: 'JSON file name same as JS file name', + sourceFile: appJS + } + ]); + }); + + }); }); describe('for middleware', function() { diff --git a/test/executor.test.js b/test/executor.test.js index bdca101..850e46e 100644 --- a/test/executor.test.js +++ b/test/executor.test.js @@ -291,6 +291,34 @@ describe('executor', function() { done(); }); }); + + describe ('for mixins', function() { + it('defines mixins from instructions', function() { + appdir.writeFileSync('mixins/example.js', + 'module.exports = ' + + 'function(Model, options) {}'); + + appdir.writeFileSync('mixins/time-stamps.js', + 'module.exports = ' + + 'function(Model, options) {}'); + + appdir.writeConfigFileSync('mixins/time-stamps.json', { + name: 'Timestamping' + }); + + var options = { + appRootDir: appdir.PATH, + mixinDirs: ['./mixins'] + }; + + boot(app, options); + + var modelBuilder = app.registry.modelBuilder; + + var registry = modelBuilder.mixins.mixins; + expect(Object.keys(registry)).to.eql(['Example', 'Timestamping']); + }); + }); }); describe('with PaaS and npm env variables', function() { diff --git a/test/fixtures/browser-app/mixins/time-stamps.js b/test/fixtures/browser-app/mixins/time-stamps.js new file mode 100644 index 0000000..c126e8b --- /dev/null +++ b/test/fixtures/browser-app/mixins/time-stamps.js @@ -0,0 +1,5 @@ +module.exports = function(Model, options) { + + Model.timeStampsMixin = true; + +}; diff --git a/test/fixtures/browser-app/models/customer.json b/test/fixtures/browser-app/models/customer.json index c1df7a5..ebf6b3c 100644 --- a/test/fixtures/browser-app/models/customer.json +++ b/test/fixtures/browser-app/models/customer.json @@ -1,4 +1,5 @@ { "name": "Customer", - "base": "User" + "base": "User", + "mixins": {"TimeStamps": {} } }