diff --git a/.gitignore b/.gitignore index f2b7a43bdb..022498ba1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # ignore tracking usage data config - we want a different one for each user usage-data-config.json +# ignore extension SCSS because it's dynamically generated +lib/extensions/_extensions.scss .env .sass-cache .DS_Store diff --git a/__tests__/spec/sanity-checks.js b/__tests__/spec/sanity-checks.js index 910fa811cc..7536039a97 100644 --- a/__tests__/spec/sanity-checks.js +++ b/__tests__/spec/sanity-checks.js @@ -5,6 +5,9 @@ var path = require('path') var fs = require('fs') var assert = require('assert') +function readFile (pathFromRoot) { + return fs.readFileSync(path.join(__dirname, '../../' + pathFromRoot), 'utf8') +} /** * Basic sanity checks on the dev server */ @@ -40,4 +43,66 @@ describe('The Prototype Kit', () => { expect(response.type).toBe('text/html') }) }) + + describe('extensions', () => { + it('should allow known assets to be loaded from node_modules', (done) => { + request(app) + .get('/extension-assets/govuk-frontend/all.js') + .expect('Content-Type', /application\/javascript; charset=UTF-8/) + .expect(200) + .end(function (err, res) { + if (err) { + done(err) + } else { + assert.strictEqual('' + res.text, readFile('node_modules/govuk-frontend/all.js')) + done() + } + }) + }) + + it('should allow known assets to be loaded from node_modules', (done) => { + request(app) + .get('/assets/images/favicon.ico') + .expect('Content-Type', /image\/x-icon/) + .expect(200) + .end(function (err, res) { + if (err) { + done(err) + } else { + assert.strictEqual('' + res.body, readFile('node_modules/govuk-frontend/assets/images/favicon.ico')) + done() + } + }) + }) + + it('should not expose everything', function (done) { + request(app) + .get('/assets/common.js') + .expect(404) + .end(function (err, res) { + if (err) { + done(err) + } else { + done() + } + }) + }) + + describe('misconfigured prototype kit - while upgrading kit developer did not copy over changes in /app folder', () => { + it('should still allow known assets to be loaded from node_modules', (done) => { + request(app) + .get('/node_modules/govuk-frontend/all.js') + .expect('Content-Type', /application\/javascript; charset=UTF-8/) + .expect(200) + .end(function (err, res) { + if (err) { + done(err) + } else { + assert.strictEqual('' + res.text, readFile('node_modules/govuk-frontend/all.js')) + done() + } + }) + }) + }) + }) }) diff --git a/app/assets/sass/application.scss b/app/assets/sass/application.scss index 732bab1e58..242336389c 100644 --- a/app/assets/sass/application.scss +++ b/app/assets/sass/application.scss @@ -1,8 +1,8 @@ // global styles for and

tags $govuk-global-styles: true; -// Import GOV.UK Frontend -@import "node_modules/govuk-frontend/all"; +// Import GOV.UK Frontend and any extension styles if extensions have been configured +@import "lib/extensions/extensions"; // Patterns that aren't in Frontend @import "patterns/step-by-step-navigation"; diff --git a/app/views/includes/head.html b/app/views/includes/head.html index 6d9e5d8d8a..05fcc71f56 100644 --- a/app/views/includes/head.html +++ b/app/views/includes/head.html @@ -1,2 +1,6 @@ + +{% for stylesheetUrl in extensionConfig.stylesheets %} + +{% endfor %} diff --git a/app/views/includes/scripts.html b/app/views/includes/scripts.html index aa86cc5ab9..46aff854ff 100644 --- a/app/views/includes/scripts.html +++ b/app/views/includes/scripts.html @@ -1,6 +1,10 @@ - + +{% for scriptUrl in extensionConfig.scripts %} + +{% endfor %} + {% if useAutoStoreData %} diff --git a/docs/documentation/extension-system.md b/docs/documentation/extension-system.md new file mode 100644 index 0000000000..c979d88217 --- /dev/null +++ b/docs/documentation/extension-system.md @@ -0,0 +1,3 @@ +# Extension system + +The extension system information should go here before it's adopted into GOVUK. diff --git a/docs/views/includes/scripts.html b/docs/views/includes/scripts.html index f2487ab98d..65f5c79369 100644 --- a/docs/views/includes/scripts.html +++ b/docs/views/includes/scripts.html @@ -1,6 +1,6 @@ - + {% if useAutoStoreData %} diff --git a/gulp/sass.js b/gulp/sass.js index 463e417707..1b33eed74c 100644 --- a/gulp/sass.js +++ b/gulp/sass.js @@ -8,9 +8,19 @@ const gulp = require('gulp') const sass = require('gulp-sass') const sourcemaps = require('gulp-sourcemaps') +const path = require('path') +const fs = require('fs') +const extensions = require('../lib/extensions/extensions') const config = require('./config.json') +gulp.task('sass-extensions', function (done) { + const fileContents = '$govuk-extensions-url-context: "/extension-assets"; ' + extensions.getFileSystemPaths('sass') + .map(filePath => `@import "${filePath.split(path.sep).join('/')}";`) + .join('\n') + fs.writeFile(path.join(config.paths.lib + 'extensions', '_extensions.scss'), fileContents, done) +}) + gulp.task('sass', function () { return gulp.src(config.paths.assets + '/sass/*.scss') .pipe(sourcemaps.init()) diff --git a/gulpfile.js b/gulpfile.js index 91e349b546..3b4702b22a 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -18,6 +18,7 @@ requireDir('./gulp', { recurse: true }) // We'll keep our top-level tasks in this file so that they are defined at the end of the chain, after their dependencies. gulp.task('generate-assets', gulp.series( 'clean', + 'sass-extensions', gulp.parallel( 'sass', 'copy-assets', diff --git a/lib/extensions/extensions.js b/lib/extensions/extensions.js new file mode 100644 index 0000000000..0d18746945 --- /dev/null +++ b/lib/extensions/extensions.js @@ -0,0 +1,174 @@ +/** + * Extensions.js (Use with caution) + * + * Experimental feature which is likely to change. + * This file returns helper methods to enable services to include + * their own departmental frontend(Styles, Scripts, nunjucks etc) + * + * Module.exports + * getPublicUrls: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * returns array of urls for a type (script, stylesheet, nunjucks etc). + * getFileSystemPaths: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * returns array paths to the file in the filesystem for a type (script, stylesheet, nunjucks etc) + * getPublicUrlAndFileSystemPaths: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * returns Array of objects, each object is an extension and each obj has the filesystem & public url for the given type + * getAppConfig: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * Returns an object containing two keys(scripts & stylesheets), each item contains an array of full paths to specific files. + * This is used in the views to output links and scripts each file. + * getAppViews: + * Params: (additionalViews | Array ) eg.extensions.getAppViews([path.join(__dirname, '/app/views/'),path.join(__dirname, '/lib/')]) + * Description: + * Returns an array of paths to nunjucks templates which is used to configure nunjucks in server.js + * setExtensionsByType + * Params: N/A + * Description: only used for test purposes to reset mocked extensions items to ensure they are up-to-date when the tests run + * + * * + */ + +// Core dependencies +const fs = require('fs') +const path = require('path') + +// Local dependencies +const appConfig = require('../../app/config') + +// Generic utilities +const removeDuplicates = arr => [...new Set(arr)] +const filterOutParentAndEmpty = part => part && part !== '..' +const objectMap = (object, mapFn) => Object.keys(object).reduce((result, key) => { + result[key] = mapFn(object[key], key) + return result +}, {}) + +// File utilities +const getPathFromProjectRoot = (...all) => { + return path.join.apply(null, [__dirname, '..', '..'].concat(all)) +} +const pathToPackageConfigFile = packageName => getPathFromProjectRoot('node_modules', packageName, 'govuk-prototype-kit.config.json') + +const readJsonFile = (filePath) => { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} +const getPackageConfig = packageName => { + if (fs.existsSync(pathToPackageConfigFile(packageName))) { + return readJsonFile(pathToPackageConfigFile(packageName)) + } else { + return {} + } +} + +// Handle errors to do with extension paths +// Example of `subject`: { packageName: 'govuk-frontend', item: '/all.js' } +const throwIfBadFilepath = subject => { + if (('' + subject.item).indexOf('\\') > -1) { + throw new Error(`Can't use backslashes in extension paths - "${subject.packageName}" used "${subject.item}".`) + } + if (!('' + subject.item).startsWith('/')) { + throw new Error(`All extension paths must start with a forward slash - "${subject.packageName}" used "${subject.item}".`) + } +} + +// Check for `baseExtensions` in config.js. If it's not there, default to `govuk-frontend` +const getBaseExtensions = () => appConfig.baseExtensions || ['govuk-frontend'] + +// Get all npm dependencies +// Get baseExtensions in the order defined in `baseExtensions` in config.js +// Then place baseExtensions before npm dependencies (and remove duplicates) +const getPackageNamesInOrder = () => { + const dependencies = readJsonFile(getPathFromProjectRoot('package.json')).dependencies || {} + const allNpmDependenciesInAlphabeticalOrder = Object.keys(dependencies).sort() + const installedBaseExtensions = getBaseExtensions() + .filter(packageName => allNpmDependenciesInAlphabeticalOrder.includes(packageName)) + + return removeDuplicates(installedBaseExtensions.concat(allNpmDependenciesInAlphabeticalOrder)) +} + +// Extensions provide items such as sass scripts, asset paths etc. +// This function groups them by type in a format which can used by getList +// Example of return +// { +// nunjucksPaths: [ +// { packageName: 'govuk-frontend', item: '/' }, +// { packageName: 'govuk-frontend', item: '/components'} +// ], +// scripts: [ +// { packageName: 'govuk-frontend', item: '/all.js' } +// ] +// assets: [ +// { packageName: 'govuk-frontend', item: '/assets' } +// ], +// sass: [ +// { packageName: 'govuk-frontend', item: '/all.scss' } +// ]} +const getExtensionsByType = () => { + return getPackageNamesInOrder() + .reduce((accum, packageName) => Object.assign({}, accum, objectMap( + getPackageConfig(packageName), + (listOfItemsForType, type) => (accum[type] || []) + .concat([].concat(listOfItemsForType).map(item => ({ + packageName, + item + }))) + )), {}) +} + +let extensionsByType + +const setExtensionsByType = () => { + extensionsByType = getExtensionsByType() +} + +setExtensionsByType() + +// The hard-coded reference to govuk-frontend allows us to soft launch without a breaking change. After a hard launch +// govuk-frontend assets will be served on /extension-assets/govuk-frontend +const getPublicUrl = config => { + if (config.item === '/assets' && config.packageName === 'govuk-frontend') { + return '/assets' + } else { + return ['', 'extension-assets', config.packageName] + .concat(config.item.split('/').filter(filterOutParentAndEmpty)) + .map(encodeURIComponent) + .join('/') + } +} + +const getFileSystemPath = config => { + throwIfBadFilepath(config) + return getPathFromProjectRoot('node_modules', + config.packageName, + config.item.split('/').filter(filterOutParentAndEmpty).join(path.sep)) +} + +const getPublicUrlAndFileSystemPath = config => ({ + fileSystemPath: getFileSystemPath(config), + publicUrl: getPublicUrl(config) +}) + +const getList = type => extensionsByType[type] || [] + +// Exports +const self = module.exports = { + getPublicUrls: type => getList(type).map(getPublicUrl), + getFileSystemPaths: type => getList(type).map(getFileSystemPath), + getPublicUrlAndFileSystemPaths: type => getList(type).map(getPublicUrlAndFileSystemPath), + getAppConfig: _ => ({ + scripts: self.getPublicUrls('scripts'), + stylesheets: self.getPublicUrls('stylesheets') + }), + getAppViews: additionalViews => self + .getFileSystemPaths('nunjucksPaths') + .reverse() + .concat(additionalViews || []), + + setExtensionsByType // exposed only for testing purposes +} diff --git a/lib/extensions/extensions.test.js b/lib/extensions/extensions.test.js new file mode 100644 index 0000000000..7a443dc233 --- /dev/null +++ b/lib/extensions/extensions.test.js @@ -0,0 +1,474 @@ +/* eslint-env jest */ +/* global spyOn */ +// NPM dependencies +const path = require('path') +const fs = require('fs') +const appConfig = require('../../app/config') + +// Local dependencies +const extensions = require('./extensions.js') + +// Local variables +const rootPath = path.join(__dirname, '..', '..') +let testScope + +// helpers +const joinPaths = arr => arr.map(x => path.join.apply(null, [rootPath].concat(x))) + +describe('extensions', () => { + beforeEach(() => { + testScope = { + originalValues: { + appConfigBaseExtensions: appConfig.baseExtensions + }, + fileSystem: {} + } + addFileToMockFileSystem(['package.json'], fs.readFileSync('package.json', 'utf8')) + addFileToMockFileSystem(['node_modules', 'govuk-frontend', 'govuk-prototype-kit.config.json'], '{"nunjucksPaths": ["/","/components"],"scripts": ["/all.js"],"assets": ["/assets"],"sass": ["/all.scss"]}') + setupFakeFilesystem() + extensions.setExtensionsByType() + }) + afterEach(() => { + jest.clearAllMocks() + appConfig.baseExtensions = testScope.originalValues.appConfigBaseExtensions + }) + + describe('Lookup file system paths', () => { + it('should lookup asset paths as file system paths', () => { + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'assets'] + ])) + }) + it('should not allow traversing the file system', () => { + mockExtensionConfig('govuk-frontend', { assets: ['/abc/../../../../../def'] }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'abc', 'def'] + ])) + }) + it('should show installed extensions asset paths as file system paths', () => { + delete appConfig.baseExtensions + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'another-frontend', 'abc'], + ['node_modules', 'another-frontend', 'def'], + ['node_modules', 'hmrc-frontend', 'ghi'], + ['node_modules', 'hmrc-frontend', 'jkl'] + ])) + }) + it('should follow strict alphabetical order when no base extensions used', () => { + appConfig.baseExtensions = [] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'another-frontend', 'abc'], + ['node_modules', 'another-frontend', 'def'], + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'hmrc-frontend', 'ghi'], + ['node_modules', 'hmrc-frontend', 'jkl'] + ])) + }) + it('should put specified baseExtensions at the top', () => { + appConfig.baseExtensions = ['hmrc-frontend', 'govuk-frontend'] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'hmrc-frontend', 'ghi'], + ['node_modules', 'hmrc-frontend', 'jkl'], + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'another-frontend', 'abc'], + ['node_modules', 'another-frontend', 'def'] + ])) + }) + it('should show installed extensions asset paths as file system paths', () => { + mockExtensionConfig('hmrc-frontend', { + assets: ['/abc', '/def'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'hmrc-frontend', 'abc'], + ['node_modules', 'hmrc-frontend', 'def'] + ])) + }) + it('should lookup scripts paths as file system paths', () => { + expect(extensions.getFileSystemPaths('scripts')).toEqual(joinPaths([ + 'node_modules/govuk-frontend/all.js' + ])) + }) + it('should not break when asking for an extension key which isn\'t used', function () { + expect(extensions.getFileSystemPaths('thisListDoesNotExist')).toEqual([]) + }) + }) + + describe('Lookup public URLs', () => { + it('should show installed extensions asset paths as file system paths', () => { + delete appConfig.baseExtensions + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrls('assets')).toEqual([ + '/assets', + '/extension-assets/another-frontend/abc', + '/extension-assets/another-frontend/def', + '/extension-assets/hmrc-frontend/ghi', + '/extension-assets/hmrc-frontend/jkl' + ]) + }) + it('should follow strict alphabetical order when no base extensions used', () => { + appConfig.baseExtensions = [] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrls('assets')).toEqual([ + '/extension-assets/another-frontend/abc', + '/extension-assets/another-frontend/def', + '/assets', + '/extension-assets/hmrc-frontend/ghi', + '/extension-assets/hmrc-frontend/jkl' + ]) + }) + it('should put specified baseExtensions at the top', () => { + appConfig.baseExtensions = ['hmrc-frontend', 'govuk-frontend'] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrls('assets')).toEqual([ + '/extension-assets/hmrc-frontend/ghi', + '/extension-assets/hmrc-frontend/jkl', + '/assets', + '/extension-assets/another-frontend/abc', + '/extension-assets/another-frontend/def' + ]) + }) + it('should url encode each part', () => { + mockExtensionConfig('mine', { assets: ['/abc:def'] }) + mockUninstallExtension('govuk-frontend') + + expect(extensions.getPublicUrls('assets')).toEqual(['/extension-assets/mine/abc%3Adef']) + }) + }) + + describe('Lookup public URLs with file system paths', () => { + it('should show installed extensions asset paths as file system paths', () => { + delete appConfig.baseExtensions + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrlAndFileSystemPaths('assets')).toEqual([ + { + publicUrl: '/assets', + fileSystemPath: path.join(rootPath, 'node_modules', 'govuk-frontend', 'assets') + }, + { + publicUrl: '/extension-assets/another-frontend/abc', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'abc') + }, + { + publicUrl: '/extension-assets/another-frontend/def', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'def') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/ghi', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'ghi') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/jkl', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'jkl') + } + ]) + }) + it('should follow strict alphabetical order when no base extensions used', () => { + appConfig.baseExtensions = [] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrlAndFileSystemPaths('assets')).toEqual([ + { + publicUrl: '/extension-assets/another-frontend/abc', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'abc') + }, + { + publicUrl: '/extension-assets/another-frontend/def', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'def') + }, + { + publicUrl: '/assets', + fileSystemPath: path.join(rootPath, 'node_modules', 'govuk-frontend', 'assets') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/ghi', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'ghi') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/jkl', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'jkl') + } + ]) + }) + it('should put specified baseExtensions at the top', () => { + appConfig.baseExtensions = ['hmrc-frontend', 'govuk-frontend'] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrlAndFileSystemPaths('assets')).toEqual([ + { + publicUrl: '/extension-assets/hmrc-frontend/ghi', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'ghi') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/jkl', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'jkl') + }, + { + publicUrl: '/assets', + fileSystemPath: path.join(rootPath, 'node_modules', 'govuk-frontend', 'assets') + }, + { + publicUrl: '/extension-assets/another-frontend/abc', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'abc') + }, + { + publicUrl: '/extension-assets/another-frontend/def', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'def') + } + ]) + }) + it('should url encode each part', () => { + mockExtensionConfig('mine', { assets: ['/abc:def'] }) + mockUninstallExtension('govuk-frontend') + + expect(extensions.getPublicUrls('assets')).toEqual(['/extension-assets/mine/abc%3Adef']) + }) + it('should not break when asking for an extension key which isn\'t used', function () { + expect(extensions.getPublicUrls('anotherListThatDoesntExist')).toEqual([]) + }) + }) + + describe('getAppViews', () => { + it('should be a function', () => { + expect(extensions.getAppViews).toBeInstanceOf(Function) + }) + + it('should output govuk-frontend nunjucks paths as an array', () => { + expect(extensions.getAppViews()).toEqual(joinPaths([ + 'node_modules/govuk-frontend/components', + 'node_modules/govuk-frontend' + ])) + }) + + it('should also output hmcts-frontend nunjucks paths after it is installed', () => { + mockExtensionConfig('hmcts-frontend', { + nunjucksPaths: [ + '/my-components', + '/my-layouts' + ] + }) + + expect(extensions.getAppViews()).toEqual(joinPaths([ + 'node_modules/hmcts-frontend/my-layouts', + 'node_modules/hmcts-frontend/my-components', + 'node_modules/govuk-frontend/components', + 'node_modules/govuk-frontend' + ])) + }) + + it('should not output any nunjucks paths when frontends are uninstalled', () => { + mockUninstallExtension('govuk-frontend') + + expect(extensions.getAppViews()).toEqual([]) + }) + + it('should also output provided paths in the array', () => { + expect(extensions.getAppViews(joinPaths([ + '/app/views', + '/lib' + ]))).toEqual(joinPaths([ + 'node_modules/govuk-frontend/components', + 'node_modules/govuk-frontend', + '/app/views', + '/lib' + ])) + }) + + it('should output any provided paths in the array', () => { + expect(extensions.getAppViews([ + '/my-new-views-directory' + ])).toEqual([ + path.join(rootPath, 'node_modules/govuk-frontend/components'), + path.join(rootPath, 'node_modules/govuk-frontend'), + '/my-new-views-directory' + ]) + }) + }) + + describe('getAppConfig', () => { + it('returns an object', () => { + expect(extensions.getAppConfig()).toBeInstanceOf(Object) + }) + + it('should have script and stylesheet keys', () => { + expect(Object.keys(extensions.getAppConfig())).toEqual(['scripts', 'stylesheets']) + }) + + it('should return a list of public urls for the scripts', () => { + expect(extensions.getAppConfig().scripts).toEqual([ + '/extension-assets/govuk-frontend/all.js' + ]) + }) + + it('should return a list of public urls for the stylesheets', () => { + expect(extensions.getAppConfig().stylesheets).toEqual([]) + }) + + it('should include installed extensions', () => { + mockExtensionConfig('my-extension', { scripts: ['/abc/def/ghi.js'] }) + expect(extensions.getAppConfig().scripts).toEqual([ + '/extension-assets/govuk-frontend/all.js', + '/extension-assets/my-extension/abc/def/ghi.js' + ]) + }) + + it('should return a list of public urls for the stylesheets', () => { + expect(extensions.getAppConfig().stylesheets).toEqual([]) + }) + + it('should include installed extensions', () => { + mockExtensionConfig('my-extension', { stylesheets: ['/abc/def/ghi.css'] }) + expect(extensions.getAppConfig().stylesheets).toEqual([ + '/extension-assets/my-extension/abc/def/ghi.css' + ]) + }) + }) + + describe('error handling', () => { + it('should cope with keys which aren\'t arrays', () => { + mockExtensionConfig('my-fixable-extension', { stylesheets: '/abc.css' }) + mockExtensionConfig('another-fixable-extension', { stylesheets: '/abc.css' }) + + expect(extensions.getAppConfig().stylesheets).toEqual([ + '/extension-assets/another-fixable-extension/abc.css', + '/extension-assets/my-fixable-extension/abc.css' + ]) + }) + it('should throw if paths use backslashes', () => { + mockExtensionConfig('my-unfixable-extension', { stylesheets: '\\abc\\def.css' }) + mockExtensionConfig('another-fixable-extension', { stylesheets: ['/abc.css'] }) + + const expectedError = new Error('Can\'t use backslashes in extension paths - "my-unfixable-extension" used "\\abc\\def.css".') + + expect(() => { + extensions.getFileSystemPaths('stylesheets') + }).toThrow(expectedError) + + expect(() => { + extensions.getPublicUrlAndFileSystemPaths('stylesheets') + }).toThrow(expectedError) + }) + it('should throw if paths use backslashes further into the path', () => { + mockExtensionConfig('my-other-unfixable-extension', { stylesheets: ['/abc\\def.css'] }) + const expectedError2 = new Error('Can\'t use backslashes in extension paths - "my-other-unfixable-extension" used "/abc\\def.css".') + + expect(() => { + extensions.getFileSystemPaths('stylesheets') + }).toThrow(expectedError2) + + expect(() => { + extensions.getPublicUrlAndFileSystemPaths('stylesheets') + }).toThrow(expectedError2) + }) + it('should throw if it doesn\'t start with a forward slash', () => { + mockExtensionConfig('yet-another-unfixable-extension', { stylesheets: ['abc.css'] }) + + const noLeadingForwardSlashError = new Error('All extension paths must start with a forward slash - "yet-another-unfixable-extension" used "abc.css".') + + expect(() => { + extensions.getFileSystemPaths('stylesheets') + }).toThrow(noLeadingForwardSlashError) + + expect(() => { + extensions.getPublicUrlAndFileSystemPaths('stylesheets') + }).toThrow(noLeadingForwardSlashError) + }) + }) + + const setupFakeFilesystem = () => { + const prepFilePath = filePath => { + return (filePath).replace(rootPath + path.sep, '').split(path.sec) + } + + spyOn(fs, 'readFileSync').and.callFake(function (filePath) { + const trimmedPath = prepFilePath(filePath) + if (doesFileExitInMockFileSystem(trimmedPath)) { + return readFileFromMockFileSystem(trimmedPath) + } else { + const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) + err.code = 'ENOENT' + throw err + } + }) + spyOn(fs, 'existsSync').and.callFake(filePath => doesFileExitInMockFileSystem(prepFilePath(filePath))) + } + + const addFileToMockFileSystem = (pathParts, content) => { + testScope.fileSystem[path.join(...pathParts)] = content + } + + const readFileFromMockFileSystem = (pathParts) => testScope.fileSystem[path.join(...pathParts)] + + const doesFileExitInMockFileSystem = (pathParts) => { + return testScope.fileSystem.hasOwnProperty(path.join(...pathParts)) + } + + const mockInstallExtension = (packageName, version = '^0.0.1') => { + const existingPackageJson = JSON.parse(readFileFromMockFileSystem(['package.json'])) + existingPackageJson.dependencies[packageName] = version + addFileToMockFileSystem(['package.json'], JSON.stringify(existingPackageJson)) + extensions.setExtensionsByType() + } + + const mockUninstallExtension = (packageName) => { + const existingPackageJson = JSON.parse(readFileFromMockFileSystem(['package.json'])) + if (!existingPackageJson.dependencies.hasOwnProperty(packageName)) { + throw new Error(`Could not uninstall '${packageName}' as it is not installed`) + } + delete existingPackageJson.dependencies[packageName] + addFileToMockFileSystem(['package.json'], JSON.stringify(existingPackageJson)) + extensions.setExtensionsByType() + } + + const mockExtensionConfig = (packageName, config = {}, version) => { + addFileToMockFileSystem(['node_modules', packageName, 'govuk-prototype-kit.config.json'], JSON.stringify(config, null, 2)) + mockInstallExtension(packageName, version) + } +}) diff --git a/lib/middleware/extensions/extensions.js b/lib/middleware/extensions/extensions.js new file mode 100644 index 0000000000..9475ae9cd5 --- /dev/null +++ b/lib/middleware/extensions/extensions.js @@ -0,0 +1,17 @@ +const express = require('express') +const extensions = require('../../extensions/extensions') +const router = express.Router() + +// Serve assets from extensions +function setupPathsFor (item) { + extensions.getPublicUrlAndFileSystemPaths(item) + .forEach(paths => { + router.use(paths.publicUrl, express.static(paths.fileSystemPath)) + }) +} + +setupPathsFor('scripts') +setupPathsFor('stylesheets') +setupPathsFor('assets') + +module.exports = router diff --git a/package.json b/package.json index 2d5bf71325..e714a24a5f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "express-writer": "0.0.4", "fancy-log": "^1.3.3", "govuk-elements-sass": "^3.1.3", - "govuk-frontend": "^2.6.0", + "govuk-frontend": "^2.7.0", "govuk_frontend_toolkit": "^7.5.0", "govuk_template_jinja": "^0.24.1", "gulp": "^4.0.0", diff --git a/server.js b/server.js index 28fb9f978c..3d58729cbe 100644 --- a/server.js +++ b/server.js @@ -14,12 +14,16 @@ const cookieParser = require('cookie-parser') dotenv.config() // Local dependencies -const authentication = require('./lib/middleware/authentication/authentication.js') +const middleware = [ + require('./lib/middleware/authentication/authentication.js'), + require('./lib/middleware/extensions/extensions.js') +] const config = require('./app/config.js') const documentationRoutes = require('./docs/documentation_routes.js') const packageJson = require('./package.json') const routes = require('./app/routes.js') const utils = require('./lib/utils.js') +const extensions = require('./lib/extensions/extensions.js') // Variables for v6 backwards compatibility // Set false by default, then turn on if we find /app/v6/routes.js @@ -76,16 +80,13 @@ if (isSecure) { app.set('trust proxy', 1) // needed for secure cookies on heroku } -// Authentication middleware -app.use(authentication) +middleware.forEach(func => app.use(func)) // Set up App -var appViews = [ - path.join(__dirname, '/node_modules/govuk-frontend/'), - path.join(__dirname, '/node_modules/govuk-frontend/components'), +var appViews = extensions.getAppViews([ path.join(__dirname, '/app/views/'), path.join(__dirname, '/lib/') -] +]) var nunjucksConfig = { autoescape: true, @@ -109,9 +110,8 @@ app.set('view engine', 'html') // Middleware to serve static assets app.use('/public', express.static(path.join(__dirname, '/public'))) -app.use('/assets', express.static(path.join(__dirname, 'node_modules', 'govuk-frontend', 'assets'))) -// Serve govuk-frontend in /public +// Serve govuk-frontend in from node_modules (so not to break pre-extenstions prototype kits) app.use('/node_modules/govuk-frontend', express.static(path.join(__dirname, '/node_modules/govuk-frontend'))) // Set up documentation app @@ -178,6 +178,8 @@ app.locals.cookieText = config.cookieText app.locals.promoMode = promoMode app.locals.releaseVersion = 'v' + releaseVersion app.locals.serviceName = config.serviceName +// extensionConfig sets up variables used to add the scripts and stylesheets to each page. +app.locals.extensionConfig = extensions.getAppConfig() // Session uses service name to avoid clashes with other prototypes const sessionName = 'govuk-prototype-kit-' + (Buffer.from(config.serviceName, 'utf8')).toString('hex')