Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extensions Framework #613

Merged
merged 3 commits into from
Feb 14, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
65 changes: 65 additions & 0 deletions __tests__/spec/sanity-checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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()
}
})
})
})
})
})
4 changes: 2 additions & 2 deletions app/assets/sass/application.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// global styles for <a> and <p> 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";
Expand Down
4 changes: 4 additions & 0 deletions app/views/includes/head.html
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
<!--[if lte IE 8]><link href="/public/stylesheets/application-ie8.css" rel="stylesheet" type="text/css" /><![endif]-->
<!--[if gt IE 8]><!--><link href="/public/stylesheets/application.css" media="all" rel="stylesheet" type="text/css" /><!--<![endif]-->

{% for stylesheetUrl in extensionConfig.stylesheets %}
<link href="{{ stylesheetUrl }}" rel="stylesheet" type="text/css" />
{% endfor %}
6 changes: 5 additions & 1 deletion app/views/includes/scripts.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<!-- Javascript -->
<script src="/public/javascripts/jquery-1.11.3.js"></script>
<script src="/node_modules/govuk-frontend/all.js"></script>

{% for scriptUrl in extensionConfig.scripts %}
<script src="{{scriptUrl}}"></script>
{% endfor %}

<script src="/public/javascripts/application.js"></script>

{% if useAutoStoreData %}
Expand Down
3 changes: 3 additions & 0 deletions docs/documentation/extension-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Extension system

The extension system information should go here before it's adopted into GOVUK.
2 changes: 1 addition & 1 deletion docs/views/includes/scripts.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- Javascript -->
<script src="/public/javascripts/jquery-1.11.3.js"></script>
<script src="/node_modules/govuk-frontend/all.js"></script>
<script src="/extension-assets/govuk-frontend/all.js"></script>
<script src="/public/javascripts/docs.js"></script>

{% if useAutoStoreData %}
Expand Down
10 changes: 10 additions & 0 deletions gulp/sass.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
174 changes: 174 additions & 0 deletions lib/extensions/extensions.js
Original file line number Diff line number Diff line change
@@ -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('/')) {
joelanman marked this conversation as resolved.
Show resolved Hide resolved
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
}
Loading