diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6299e0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +*.swp +*.swo +node_modules +!test/webpack/node_modules/@folio/app2/node_modules/ +!test/webpack/node_modules/ +npm-debug.log +static +README.html +OVERVIEW.html +yarn.lock +yarn-error.log +artifacts +dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea018de --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Stripes Webpack + +Copyright (C) 2017-2021 The Open Library Foundation + +This software is distributed under the terms of the Apache License, +Version 2.0. See the file "[LICENSE](LICENSE)" for more information. + + +## Introduction + +Stripes Webpack contains webpack config files used for building and serving Folio bundles via [stripes-cli](https://github.com/folio-org/stripes-cli). + + +## Additional information + +See project [STRIPES](https://issues.folio.org/browse/STRIPES) +at the [FOLIO issue tracker](https://dev.folio.org/guidelines/issue-tracker/). + +Other FOLIO Developer documentation is at [dev.folio.org](https://dev.folio.org/) + diff --git a/default-assets/branding.js b/default-assets/branding.js new file mode 100644 index 0000000..74ac6d9 --- /dev/null +++ b/default-assets/branding.js @@ -0,0 +1,12 @@ +// These default branding values are used when no branding is present in stripes.config.js +// Default src are prefixed with '@folio/stripes-core' so they can be imported +// and processed through the webpack loaders from the stripes-core in use. +module.exports = { + logo: { + src: `${__dirname}/folio-logo.svg`, + alt: 'FOLIO', + }, + favicon: { + src: `${__dirname}/favicon.svg`, + }, +}; diff --git a/default-assets/favicon.svg b/default-assets/favicon.svg new file mode 100644 index 0000000..6ce61fc --- /dev/null +++ b/default-assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/default-assets/folio-logo.svg b/default-assets/folio-logo.svg new file mode 100644 index 0000000..ddd2390 --- /dev/null +++ b/default-assets/folio-logo.svg @@ -0,0 +1 @@ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..c83d00b --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + FOLIO + + + +
+
+ + diff --git a/package.json b/package.json new file mode 100644 index 0000000..80a2007 --- /dev/null +++ b/package.json @@ -0,0 +1,83 @@ +{ + "name": "@folio/stripes-webpack", + "version": "1.0.0", + "description": "The webpack config for stripes", + "license": "Apache-2.0", + "publishConfig": { + "registry": "https://repository.folio.org/repository/npm-folio/" + }, + "scripts": { + "test": "mocha --opts test/mocha.opts './test/webpack/**/*.js'" + }, + "engines": { + "node": ">=10.0.0" + }, + "dependencies": { + "@babel/core": "^7.8.0", + "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-decorators": "^7.0.0", + "@babel/plugin-proposal-export-namespace-from": "^7.0.0", + "@babel/plugin-proposal-function-sent": "^7.0.0", + "@babel/plugin-proposal-numeric-separator": "^7.0.0", + "@babel/plugin-proposal-throw-expressions": "^7.0.0", + "@babel/plugin-syntax-import-meta": "^7.0.0", + "@babel/preset-env": "^7.0.0", + "@babel/preset-flow": "^7.7.4", + "@babel/preset-react": "^7.7.4", + "@babel/preset-typescript": "^7.7.7", + "@babel/register": "^7.0.0", + "@bigtest/mirage": "^0.0.1", + "@hot-loader/react-dom": "^16.8.6", + "add-asset-html-webpack-plugin": "^3.1.3", + "autoprefixer": "^9.1.1", + "awesome-typescript-loader": "^5.2.0", + "babel-loader": "^8.0.0", + "babel-plugin-lodash": "^3.3.4", + "babel-plugin-remove-jsx-attributes": "^0.0.2", + "commander": "^2.9.0", + "connect-history-api-fallback": "^1.3.0", + "core-js": "^3.6.1", + "css-loader": "^1.0.0", + "debug": "^4.0.1", + "duplicate-package-checker-webpack-plugin": "^3.0.0", + "express": "^4.14.0", + "favicons-webpack-plugin": "^3.0.1", + "file-loader": "^1.1.11", + "handlebars-loader": "^1.7.1", + "hard-source-webpack-plugin": "^0.12.0", + "html-webpack-plugin": "^4.0.0-beta.10", + "lodash-webpack-plugin": "^0.11.5", + "mini-css-extract-plugin": "^0.4.0", + "optimize-css-assets-webpack-plugin": "^5.0.0", + "postcss": "^7.0.2", + "postcss-calc": "^6.0.0", + "postcss-color-function": "^4.0.0", + "postcss-custom-media": "^6.0.0", + "postcss-custom-properties": "^10.0.0", + "postcss-import": "^12.0.0", + "postcss-loader": "^3.0.0", + "postcss-media-minmax": "^3.0.0", + "postcss-nesting": "^6.0.0", + "postcss-url": "^8.0.0", + "regenerator-runtime": "^0.13.3", + "rimraf": "^2.5.4", + "rtl-detect": "^1.0.2", + "semver": "^7.1.3", + "serialize-javascript": "^5.0.0", + "style-loader": "^1.0.0", + "svgo": "^1.2.2", + "svgo-loader": "^2.2.1", + "tapable": "^1.0.0", + "typescript": "^2.8.1", + "use-deep-compare": "^1.1.0", + "uuid": "^3.0.0", + "webpack": "^4.10.2", + "webpack-dev-middleware": "^3.1.3", + "webpack-hot-middleware": "^2.22.2", + "webpack-virtual-modules": "^0.1.10" + }, + "devDependencies": { + "mocha": "^6.1.3", + "mocha-junit-reporter": "^1.17.0" + } +} diff --git a/stripes.js b/stripes.js new file mode 100755 index 0000000..a6884bf --- /dev/null +++ b/stripes.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ + +const commander = require('commander'); +const path = require('path'); +const stripes = require('./webpack/stripes-node-api'); +const packageJSON = require('./package.json'); + +commander.version(packageJSON.version); + +// Display error to the console and exit +function processError(err) { + if (err) { + console.error(err); + } + process.exit(1); +} + +// Display webpack output to the console +function processStats(stats) { + console.log(stats.toString({ + chunks: false, + colors: true, + })); + // Check for webpack compile errors and exit + if (stats.hasErrors()) { + processError(); + } +} + +commander + .command('dev') + .option('--port [port]', 'Port') + .option('--host [host]', 'Host') + .option('--cache', 'Use HardSourceWebpackPlugin cache') + .option('--devtool [devtool]', 'Use another value for devtool instead of "inline-source-map"') + .arguments('') + .description('Launch a webpack-dev-server') + .action((stripesConfigFile, options) => { + // eslint-disable-next-line global-require,import/no-dynamic-require + const stripesConfig = require(path.resolve(stripesConfigFile)); + stripes.serve(stripesConfig, options); + }); + +commander + .command('build') + .option('--publicPath [publicPath]', 'publicPath') + .option('--sourcemap', 'include sourcemaps in build') + .option('--no-minify', 'do not minify JavaScript') + .arguments(' ') + .description('Build a tenant bundle') + .action((stripesConfigFile, outputPath, options) => { + // eslint-disable-next-line global-require,import/no-dynamic-require + const stripesConfig = require(path.resolve(stripesConfigFile)); + options.outputPath = outputPath; + stripes.build(stripesConfig, options) + .then(stats => processStats(stats)) + .catch(err => processError(err)); + }); + +commander.parse(process.argv); + +// output help if no command specified +if (!process.argv.slice(2).length) { + commander.outputHelp(); +} diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..c4222a9 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,3 @@ +--require @babel/register +--watch-extensions json +--recursive diff --git a/test/webpack/.eslintrc b/test/webpack/.eslintrc new file mode 100644 index 0000000..7eeefc3 --- /dev/null +++ b/test/webpack/.eslintrc @@ -0,0 +1,5 @@ +{ + "env": { + "mocha": true + } +} diff --git a/test/webpack/babel-loader-rule.spec.js b/test/webpack/babel-loader-rule.spec.js new file mode 100644 index 0000000..0be7cb2 --- /dev/null +++ b/test/webpack/babel-loader-rule.spec.js @@ -0,0 +1,42 @@ +const expect = require('chai').expect; +const babelLoaderRule = require('../../webpack/babel-loader-rule'); + +describe('The babel-loader-rule', function () { + describe('test condition function', function () { + beforeEach(function () { + this.sut = babelLoaderRule.test; + }); + + it('selects files for @folio scoped node_modules', function () { + const fileName = '/projects/folio/folio-testing-platform/node_modules/@folio/inventory/index.js'; + const result = this.sut(fileName); + expect(result).to.equal(true); + }); + + it('does not select node_modules files outside of @folio scope', function () { + const fileName = '/projects/folio/folio-testing-platform/node_modules/lodash/lodash.js'; + const result = this.sut(fileName); + expect(result).to.equal(false); + }); + + it('only selects .js file extensions', function () { + const fileName = '/project/folio/folio-testing-platform/node_modules/@folio/search/package.json'; + const result = this.sut(fileName); + expect(result).to.equal(false); + }); + + it('selects files outside of both @folio scope and node_modules', function () { + // This test case would hold true for yarn-linked modules, @folio scoped or otherwise + // Therefore this implies that we are not yarn-linking any non-@folio scoped modules + const fileName = '/projects/folio/stripes-core/src/configureLogger.js'; + const result = this.sut(fileName); + expect(result).to.equal(true); + }); + + it('does not select excluded modules', function () { + const fileName = '/projects/folio/stripes-smart-components/node_modules/@folio/react-githubish-mentions/lib/MentionMenu.js'; + const result = this.sut(fileName); + expect(result).to.equal(false); + }); + }); +}); diff --git a/test/webpack/node_modules/@folio/app1/icons/app.png b/test/webpack/node_modules/@folio/app1/icons/app.png new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/app1/icons/app.svg b/test/webpack/node_modules/@folio/app1/icons/app.svg new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/app1/package.json b/test/webpack/node_modules/@folio/app1/package.json new file mode 100644 index 0000000..3f0601f --- /dev/null +++ b/test/webpack/node_modules/@folio/app1/package.json @@ -0,0 +1,29 @@ +{ + "name": "@folio/app1", + "version": "1.2.3", + "main": "src/index.js", + "stripes": { + "actsAs": [ + "app", + "settings" + ], + "displayName": "ui-app1.meta.title", + "route": "/app1", + "actionNames": [ + "stripesHome", + "app1SortByName" + ], + "icons": [ + { + "name": "app", + "alt": "Create, view and manage app1", + "title": "Application 1" + } + ], + "okapiInterfaces": { + "users": "15.0", + "configuration": "2.0" + }, + "stripesDeps": ["@folio/stripes-dep1", "@notfolio/stripes-dep2"] + } +} diff --git a/test/webpack/node_modules/@folio/app2/icons/app.svg b/test/webpack/node_modules/@folio/app2/icons/app.svg new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/app2/node_modules/@folio/stripes-dep1/icons/thing.png b/test/webpack/node_modules/@folio/app2/node_modules/@folio/stripes-dep1/icons/thing.png new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/app2/node_modules/@folio/stripes-dep1/icons/thing.svg b/test/webpack/node_modules/@folio/app2/node_modules/@folio/stripes-dep1/icons/thing.svg new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/app2/node_modules/@folio/stripes-dep1/package.json b/test/webpack/node_modules/@folio/app2/node_modules/@folio/stripes-dep1/package.json new file mode 100644 index 0000000..8b3b7d1 --- /dev/null +++ b/test/webpack/node_modules/@folio/app2/node_modules/@folio/stripes-dep1/package.json @@ -0,0 +1,14 @@ +{ + "name": "@folio/stripes-dep1", + "version": "1.2.3", + "main": "src/index.js", + "stripes": { + "icons": [ + { + "name": "thing", + "alt": "Do thing", + "title": "Thing" + } + ] + } +} diff --git a/test/webpack/node_modules/@folio/app2/package.json b/test/webpack/node_modules/@folio/app2/package.json new file mode 100644 index 0000000..6f00a55 --- /dev/null +++ b/test/webpack/node_modules/@folio/app2/package.json @@ -0,0 +1,25 @@ +{ + "name": "@folio/app2", + "version": "1.2.3", + "main": "src/index.js", + "stripes": { + "actsAs": [ + "app", + "settings" + ], + "displayName": "ui-app2.meta.title", + "route": "/app2", + "icons": [ + { + "name": "app", + "alt": "Create, view and manage app2", + "title": "Application 2" + } + ], + "okapiInterfaces": { + "users": "15.0", + "configuration": "2.0" + }, + "stripesDeps": ["@folio/stripes-dep1"] + } +} diff --git a/test/webpack/node_modules/@folio/stripes-dep1/icons/otherthing.png b/test/webpack/node_modules/@folio/stripes-dep1/icons/otherthing.png new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/stripes-dep1/icons/otherthing.svg b/test/webpack/node_modules/@folio/stripes-dep1/icons/otherthing.svg new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/stripes-dep1/icons/thing.png b/test/webpack/node_modules/@folio/stripes-dep1/icons/thing.png new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/stripes-dep1/icons/thing.svg b/test/webpack/node_modules/@folio/stripes-dep1/icons/thing.svg new file mode 100644 index 0000000..e69de29 diff --git a/test/webpack/node_modules/@folio/stripes-dep1/package.json b/test/webpack/node_modules/@folio/stripes-dep1/package.json new file mode 100644 index 0000000..0bc5dda --- /dev/null +++ b/test/webpack/node_modules/@folio/stripes-dep1/package.json @@ -0,0 +1,19 @@ +{ + "name": "@folio/stripes-dep1", + "version": "3.4.5", + "main": "src/index.js", + "stripes": { + "icons": [ + { + "name": "thing", + "alt": "Do thing", + "title": "Thingy" + }, + { + "name": "otherthing", + "alt": "Do other thing", + "title": "Other Thing" + } + ] + } +} diff --git a/test/webpack/node_modules/@notfolio/stripes-dep2/package.json b/test/webpack/node_modules/@notfolio/stripes-dep2/package.json new file mode 100644 index 0000000..8db0fd5 --- /dev/null +++ b/test/webpack/node_modules/@notfolio/stripes-dep2/package.json @@ -0,0 +1,5 @@ +{ + "name": "@notfolio/stripes-dep2", + "version": "1.2.3", + "main": "src/index.js" +} diff --git a/test/webpack/stripes-branding-plugin.spec.js b/test/webpack/stripes-branding-plugin.spec.js new file mode 100644 index 0000000..d3eae33 --- /dev/null +++ b/test/webpack/stripes-branding-plugin.spec.js @@ -0,0 +1,116 @@ +const expect = require('chai').expect; + +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); +const defaultBranding = require('../../default-assets/branding'); +const StripesBrandingPlugin = require('../../webpack/stripes-branding-plugin'); + +// Sample data for test +const tenantBranding = { + logo: { + src: './path/to/my-logo.png', + alt: 'my alt', + }, + favicon: { + src: './path/to/my-favicon.ico', + }, +}; + +// Stub the parts of the webpack compiler that the StripesBrandingPlugin interacts with +const compilerStub = { + apply: () => {}, + plugin: () => {}, + options: { + plugins: ['something', {}, new HtmlWebpackPlugin()], // sample plugin data + }, + hooks: { + stripesConfigPluginBeforeWrite: { + tap: () => {}, + }, + make: { + tapAsync: () => {}, + tapPromise: () => {}, + }, + compilation: { + tap: () => {}, + }, + afterCompile: { + tapPromise: () => {}, + }, + emit: { + tapAsync: () => {}, + } + }, + context: '' +}; + +describe('The stripes-branding-plugin', function () { + describe('constructor', function () { + it('uses default branding', function () { + const sut = new StripesBrandingPlugin(); + expect(sut.branding).to.be.an('object').with.property('logo'); + expect(sut.branding).to.deep.include(defaultBranding); + }); + + it('accepts tenant branding', function () { + const sut = new StripesBrandingPlugin({ tenantBranding }); + expect(sut.branding).to.be.an('object').with.property('logo'); + expect(sut.branding).to.deep.include(tenantBranding); + }); + }); + + describe('apply method', function () { + it('applies the FaviconsWebpackPlugin', function () { + this.sandbox.spy(FaviconsWebpackPlugin.prototype, 'apply'); + const sut = new StripesBrandingPlugin(); + sut.apply(compilerStub); + + expect(FaviconsWebpackPlugin.prototype.apply).to.have.been.calledOnce; + expect(FaviconsWebpackPlugin.prototype.apply).to.be.calledWith(compilerStub); + }); + }); + + describe('_getFaviconOptions method', function () { + it('enables all favicons when "buildAllFavicons" is true', function () { + const sut = new StripesBrandingPlugin({ buildAllFavicons: true }); + const options = sut._getFaviconOptions(); + expect(options).to.be.a('object').with.property('icons').that.includes({ + android: true, + appleIcon: true, + appleStartup: true, + coast: true, + favicons: true, + firefox: true, + windows: true, + yandex: true, + }); + }); + it('enables only standard favicons when "buildAllFavicons" is false', function () { + const sut = new StripesBrandingPlugin({ buildAllFavicons: false }); + const options = sut._getFaviconOptions(); + expect(options).to.be.a('object').with.property('icons').that.includes({ + android: false, + appleIcon: false, + appleStartup: false, + coast: false, + favicons: true, + firefox: false, + windows: false, + yandex: false, + }); + }); + }); + + describe('_initFavicon method', function () { + it('returns an absolute file path without @folio/stripes-core for default favicon', function () { + const result = StripesBrandingPlugin._initFavicon(defaultBranding.favicon.src); + expect(result).to.be.a('string').that.does.not.include('@folio/stripes-core'); + expect(result.substr(0, 1)).to.equal('/'); + }); + it('returns an absolute file path for tenant favicon', function () { + const result = StripesBrandingPlugin._initFavicon(tenantBranding.favicon.src); + expect(result).to.include(tenantBranding.favicon.src.replace('.', '')); + expect(result.substr(0, 1)).to.equal('/'); + }); + }); +}); diff --git a/test/webpack/stripes-config-plugin.spec.js b/test/webpack/stripes-config-plugin.spec.js new file mode 100644 index 0000000..635083b --- /dev/null +++ b/test/webpack/stripes-config-plugin.spec.js @@ -0,0 +1,157 @@ +const expect = require('chai').expect; + +const VirtualModulesPlugin = require('webpack-virtual-modules'); +const StripesConfigPlugin = require('../../webpack/stripes-config-plugin'); +const StripesTranslationsPlugin = require('../../webpack/stripes-translations-plugin'); +const StripesBrandingPlugin = require('../../webpack/stripes-branding-plugin'); +const stripesModuleParser = require('../../webpack/stripes-module-parser'); +const stripesSerialize = require('../../webpack/stripes-serialize'); + +const compilerStub = { + apply: () => {}, + plugin: () => {}, + options: { + resolve: { + alias: { + 'my-alias': '/path/to/some-module', + }, + }, + plugins: [], + }, + hooks: { + afterPlugins: { + tap: () => {}, + }, + emit: { + tapAsync: () => {}, + }, + afterEnvironment: { + tap: () => {} + }, + afterResolvers: { + tap: () => {} + }, + watchRun: { + tapAsync: () => {} + }, + }, + context: '/context/path', + warnings: [], +}; + +const mockConfig = { + modules: { + '@folio/users': {}, + '@folio/search': {}, + '@folio/developer': {}, + }, +}; + +describe('The stripes-config-plugin', function () { + describe('constructor', function () { + it('throws StripesBuildError when missing modules config', function () { + const config = {}; + try { + const sut = new StripesConfigPlugin(config); // eslint-disable-line no-unused-vars + expect('should not get here').to.equal(false); + } catch (err) { + expect(err.message).match(/was not provided a "modules" object/); + } + }); + + it('omits branding config (handled by its own plugin)', function () { + const config = { + modules: {}, + branding: {}, + }; + const sut = new StripesConfigPlugin(config); + expect(sut.options).to.have.property('modules'); + expect(sut.options).to.not.have.property('branding'); + }); + }); + + describe('apply method', function () { + beforeEach(function () { + this.sandbox.stub(stripesModuleParser, 'parseAllModules').returns({ app: ['something'] }); + this.sut = new StripesConfigPlugin(mockConfig); + }); + + afterEach(function () { + delete compilerStub.hooks.stripesConfigPluginBeforeWrite; + }); + + it('applies a virtual module', function () { + this.sandbox.spy(VirtualModulesPlugin.prototype, 'apply'); + this.sut.apply(compilerStub); + + expect(VirtualModulesPlugin.prototype.apply).to.have.been.calledOnce; + expect(VirtualModulesPlugin.prototype.apply).to.be.calledWith(compilerStub); + }); + + it('registers the "after-plugins" hook', function () { + this.sandbox.spy(compilerStub.hooks.afterPlugins, 'tap'); + this.sut.apply(compilerStub); + expect(compilerStub.hooks.afterPlugins.tap).to.have.been.calledWith('StripesConfigPlugin'); + }); + }); + + describe('afterPlugins method', function () { + beforeEach(function () { + this.sandbox.stub(stripesModuleParser, 'parseAllModules').returns({ config: 'something', metadata: 'something' }); + this.sandbox.stub(VirtualModulesPlugin.prototype, 'writeModule').returns({}); + this.sandbox.stub(stripesSerialize, 'serializeWithRequire').returns({}); + this.sut = new StripesConfigPlugin(mockConfig); + + compilerStub.plugins = []; + + const translationPlugin = new StripesTranslationsPlugin({ config: {} }); + translationPlugin.allFiles = { en: '/translations/stripes-core/en.json' }; + compilerStub.options.plugins.push(translationPlugin); + + const brandingPlugin = new StripesBrandingPlugin({}); + brandingPlugin.serializedBranding = JSON.stringify({ logo: { alt: 'Future Of Libraries Is Open' } }); + compilerStub.options.plugins.push(brandingPlugin); + + this.sut.apply(compilerStub); + }); + + afterEach(function () { + delete compilerStub.hooks.stripesConfigPluginBeforeWrite; + }); + + it('calls virtualModule.writeModule()', function () { + this.sut.afterPlugins(compilerStub); + expect(this.sut.virtualModule.writeModule).to.have.been.calledOnce; + }); + + it('writes serialized config to virtual module', function () { + this.sut.afterPlugins(compilerStub); + const writeModuleArgs = this.sut.virtualModule.writeModule.getCall(0).args; + expect(writeModuleArgs[0]).to.be.a('string').that.equals('node_modules/stripes-config.js'); + + // TODO: More thorough analysis of the generated virtual module + expect(writeModuleArgs[1]).to.be.a('string').with.match(/export { okapi, config, modules, branding, translations, metadata, icons }/); + }); + }); + + describe('processWarnings method', function () { + beforeEach(function () { + compilerStub.warnings = []; + this.sut = new StripesConfigPlugin(mockConfig); + }); + + it('assigns warnings to the Webpack compilation', function () { + this.sut.warnings = ['uh-oh', 'something happened']; + this.sut.processWarnings(compilerStub, () => {}); + expect(compilerStub.warnings).to.be.an('array').with.length(1); + expect(compilerStub.warnings[0]).to.match(/uh-oh/); + expect(compilerStub.warnings[0]).to.match(/something happened/); + }); + + it('does not assign warnings when not present', function () { + this.sut.warnings = []; + this.sut.processWarnings(compilerStub, () => {}); + expect(compilerStub.warnings).to.be.an('array').with.length(0); + }); + }); +}); diff --git a/test/webpack/stripes-module-parser.spec.js b/test/webpack/stripes-module-parser.spec.js new file mode 100644 index 0000000..7040663 --- /dev/null +++ b/test/webpack/stripes-module-parser.spec.js @@ -0,0 +1,287 @@ +const expect = require('chai').expect; +const modulePaths = require('../../webpack/module-paths'); +const { StripesModuleParser, parseAllModules } = require('../../webpack/stripes-module-parser'); + +const moduleName = '@folio/users'; +const moduleConfig = {}; +const context = '/path/to/folio-testing-platform'; +const aliases = { + react: '/path/to/node_modules/react', +}; +const enabledModules = { + '@folio/users': {}, + '@folio/search': {}, + '@folio/developer': {}, +}; +const icons = [ + { name: 'one', + alt: 'alt for one', + fileName: 'oneFile', + title: 'a title for one' }, + { name: 'two', + alt: 'alt for two', + fileName: 'twoFile', + title: 'a title for two' }, +]; +const welcomePageEntries = [ + { iconName: 'one', + headline: 'welcome headline', + description: 'welcome description' }, + { iconName: 'two', + headline: 'another welcome headline', + description: 'another welcome description' }, +]; +let mockPackageJson; + +function getMockPackageJson(actsAs = 'app') { + return function (mod) { + return { + name: mod, + description: `description for ${mod}`, + version: '1.0.0', + stripes: { + actsAs, + displayName: `display name for ${mod}`, + route: `/${mod}`, + permissionSets: [], + icons, + welcomePageEntries, + }, + }; + }; +} + +describe('The stripes-module-parser', function () { + describe('loadPackageJson method', function () { + it('throws StripesBuildError when package.json is missing', function () { + this.sandbox.stub(modulePaths, 'locateStripesModule').returns(false); + try { + this.sut = new StripesModuleParser(moduleName, moduleConfig, context, aliases); + expect('never to be called').to.equal(true); + } catch (err) { + expect(err.message).to.match(/Unable to locate/); + } + }); + }); + + describe('parsing methods', function () { + beforeEach(function () { + mockPackageJson = getMockPackageJson(); + this.packageJson = mockPackageJson('@folio/users'); + this.sandbox.stub(StripesModuleParser.prototype, 'loadModulePackageJson').callsFake(mockPackageJson); + this.sandbox.stub(modulePaths, 'tryResolve').returns(true); // Mocks finding all the icon files + this.sut = new StripesModuleParser(moduleName, moduleConfig, context, aliases); + this.sut.modulePath = '/path/to/module'; + }); + + describe('parseStripesConfig', function () { + it('returns a parsed config', function () { + const result = this.sut.parseStripesConfig('@folio/users', this.packageJson); + expect(result).to.be.an('object').with.keys( + 'module', 'getModule', 'description', 'version', 'displayName', 'route', 'welcomePageEntries', + ); + }); + + it('applies overrides from tenant config', function () { + this.sut.overrideConfig = { displayName: 'something else' }; + const result = this.sut.parseStripesConfig('@folio/users', this.packageJson); + expect(result.displayName).to.equal('something else'); + }); + + it('assigns getModule function', function () { + const result = this.sut.parseStripesConfig('@folio/users', this.packageJson); + expect(result.getModule).to.be.a('function'); + }); + }); + + describe('parseStripesMetadata', function () { + it('returns metadata', function () { + const result = this.sut.parseStripesMetadata(this.packageJson); + expect(result).to.be.an('object').with.keys( + 'name', 'version', 'description', 'license', 'feedback', 'type', 'shortTitle', 'fullTitle', + 'defaultPopoverSize', 'defaultPreviewWidth', 'helpPage', 'icons', 'welcomePageEntries', + ); + }); + }); + + describe('parseModule', function () { + it('throws StripesBuildError when stripes is missing', function () { + delete this.sut.packageJson.stripes; + try { + this.sut.parseModule(); + expect('never to be called').to.equal(true); + } catch (err) { + expect(err.message).to.match(/does not have a "stripes" key/); + } + }); + + it('throws StripesBuildError when stripes.actsAs is missing', function () { + delete this.sut.packageJson.stripes.actsAs; + try { + this.sut.parseModule(); + expect('never to be called').to.equal(true); + } catch (err) { + expect(err.message).to.match(/does not specify stripes\.actsAs/); + } + }); + }); + + describe('getIconMetadata', function () { + it('returns icon data by name', function () { + const result = this.sut.getIconMetadata(icons); + expect(result).to.be.an('object').with.all.keys('one', 'two'); + expect(result.one).to.include({ + alt: 'alt for one', + title: 'a title for one', + }); + }); + + it('warns when icons are missing', function () { + this.sut.getIconMetadata(undefined, true); + expect(this.sut.warnings[0]).to.match(/no icons defined/); + }); + + it('uses icon.fileName for building file paths', function () { + const result = this.sut.getIconMetadata(icons); + expect(result.one).to.include({ + src: '/path/to/module/icons/oneFile.svg', + }); + }); + + it('falls back to icon.name when icon.fileName is not specified', function () { + const iconsNoFileName = [ + { name: 'one', + alt: 'alt for one', + title: 'a title for one' }, + ]; + const result = this.sut.getIconMetadata(iconsNoFileName); + expect(result.one).to.include({ + src: '/path/to/module/icons/one.svg', + }); + }); + }); + + describe('buildIconFilePaths', function () { + it('returns all file variants (high/low)', function () { + const result = this.sut.buildIconFilePaths('one'); + expect(result).to.deep.include({ + high: { src: '/path/to/module/icons/one.svg' }, + low: { src: '/path/to/module/icons/one.png' }, + }); + }); + + it('returns default icon src', function () { + const result = this.sut.buildIconFilePaths('one'); + expect(result).to.deep.include({ + src: '/path/to/module/icons/one.svg', + }); + }); + + it('does not return paths for missing icons', function () { + modulePaths.tryResolve.restore(); + this.sandbox.stub(modulePaths, 'tryResolve').returns(false); + const result = this.sut.buildIconFilePaths('one'); + expect(result).to.deep.include({ + high: { src: '' }, + low: { src: '' }, + }); + }); + + it('warns for missing icons variants', function () { + modulePaths.tryResolve.restore(); + this.sandbox.stub(modulePaths, 'tryResolve').returns(false); + this.sut.buildIconFilePaths('one'); + expect(this.sut.warnings[0]).to.match(/missing file/); + }); + }); + + describe('getWelcomePageEntries', function () { + it('returns array of welcomePageEntries', function () { + const parsedIcons = this.sut.getIconMetadata(icons); + const result = this.sut.getWelcomePageEntries(welcomePageEntries, parsedIcons); + expect(result).to.be.an('array').with.length(2); + expect(result[0]).to.deep.equal(welcomePageEntries[0]); + }); + + it('warns when a missing icon is referenced', function () { + const parsedIcons = this.sut.getIconMetadata(icons); + delete parsedIcons.one; + this.sut.getWelcomePageEntries(welcomePageEntries, parsedIcons); + expect(this.sut.warnings[0]).to.match(/no matching stripes.icons/); + }); + }); + }); +}); + +describe('parseAllModules function', function () { + describe('module type is "app"', function () { + beforeEach(function () { + mockPackageJson = getMockPackageJson(); + this.sandbox.stub(StripesModuleParser.prototype, 'loadModulePackageJson').callsFake(mockPackageJson); + this.sandbox.stub(modulePaths, 'tryResolve').returns(true); // Mocks finding all the icon files + this.sut = parseAllModules; + }); + + it('returns config and metadata collections', function () { + const result = this.sut(enabledModules, context, aliases); + expect(result).to.be.an('object').with.all.keys('config', 'metadata', 'stripesDeps', 'icons', 'warnings'); + }); + + it('returns config grouped by stripes type', function () { + const result = this.sut(enabledModules, context, aliases); + expect(result.config).to.be.an('object').with.property('app').that.is.an('array'); + expect(result.config.app.length).to.equal(3); + expect(result.config.app[0]).to.be.an('object').with.keys( + 'module', 'getModule', 'description', 'version', 'displayName', 'route', 'welcomePageEntries', + ); + }); + + it('returns metadata for each module', function () { + const result = this.sut(enabledModules, context, aliases); + expect(result.metadata).to.be.an('object').with.all.keys('users', 'search', 'developer'); + expect(result.warnings).to.be.an('array').with.lengthOf(0); + }); + + it('returns warnings for each module', function () { + modulePaths.tryResolve.restore(); + this.sandbox.stub(modulePaths, 'tryResolve').returns(false); // Mock missing files + const result = this.sut(enabledModules, context, aliases); + expect(result.warnings).to.be.an('array').with.lengthOf.at.least(1); + }); + }); + + describe('module acts as "settings" and "plugin"', function () { + beforeEach(function () { + mockPackageJson = getMockPackageJson(['settings', 'plugin']); + this.sandbox.stub(StripesModuleParser.prototype, 'loadModulePackageJson').callsFake(mockPackageJson); + this.sandbox.stub(modulePaths, 'tryResolve').returns(true); // Mocks finding all the icon files + this.sut = parseAllModules; + }); + + it('actsAs settings and plugin produces the expected settings and plugin configs and no app config', function () { + const result = this.sut(enabledModules, context, aliases); + expect(result.config.app).to.be.an('array').with.lengthOf(0); + expect(result.config.settings).to.be.an('array').with.lengthOf(3); + expect(result.config.plugin).to.be.an('array').with.lengthOf(3); + }); + }); +}); + +describe('integration', function () { + const result = parseAllModules({ '@folio/app1': {}, '@folio/app2': {} }, __dirname, aliases); + it('sees the right number of apps', function () { + expect(result.config.app).to.be.an('array').with.lengthOf(2); + }); + it('sees the right number of deps', function () { + expect(Object.keys(result.stripesDeps)).to.be.an('array').with.lengthOf(2); + }); + it('lists deps sorted by version', function () { + expect(result.stripesDeps['@folio/stripes-dep1'][1].version).to.equal('3.4.5'); + }); + it('has icons from the right number of packages', function () { + expect(Object.keys(result.icons)).to.be.an('array').with.lengthOf(3); + }); + it('uses icon from the latest version', function () { + expect(result.icons['@folio/stripes-dep1'].thing.title).to.equal('Thingy'); + }); +}); diff --git a/test/webpack/stripes-serialize.spec.js b/test/webpack/stripes-serialize.spec.js new file mode 100644 index 0000000..48fb5d1 --- /dev/null +++ b/test/webpack/stripes-serialize.spec.js @@ -0,0 +1,42 @@ +const expect = require('chai').expect; + +const defaultBranding = require('../../default-assets/branding'); +const { serializeWithRequire } = require('../../webpack/stripes-serialize'); + +// Sample data for test +const tenantBranding = { + logo: { + src: './path/to/my-logo.png', + alt: 'my alt', + }, + favicon: { + src: './path/to/my-favicon.ico', + }, +}; + +describe('The stripes-serialize module', function () { + describe('serializeWithRequire function', function () { + it('maintains absolute src paths', function () { + const result = serializeWithRequire(defaultBranding); + expect(result).to.be.a('string') + .which.includes(`'${defaultBranding.logo.src}'`); + }); + + it('updates custom src paths', function () { + const result = serializeWithRequire(tenantBranding); + expect(result).to.be.a('string') + .which.includes(`'.${tenantBranding.logo.src}'`) + .and.not.include(`'${tenantBranding.logo.src}'`); + }); + + it('wraps src values in require()', function () { + const result = serializeWithRequire(tenantBranding); + expect(result).to.include('"src": require('); + }); + + it('does not wrap non-src values in require()', function () { + const result = serializeWithRequire(tenantBranding); + expect(result).to.not.include('"alt": require('); + }); + }); +}); diff --git a/test/webpack/stripes-translations-plugin.spec.js b/test/webpack/stripes-translations-plugin.spec.js new file mode 100644 index 0000000..96ddd23 --- /dev/null +++ b/test/webpack/stripes-translations-plugin.spec.js @@ -0,0 +1,244 @@ +const expect = require('chai').expect; +const fs = require('fs'); +const webpack = require('webpack'); + +const modulePaths = require('../../webpack/module-paths'); +const StripesTranslationsPlugin = require('../../webpack/stripes-translations-plugin'); + +// Stub the parts of the webpack compiler that the StripesTranslationsPlugin interacts with +const compilerStub = { + apply: () => {}, + plugin: () => {}, + options: { + output: { + publicPath: '/', + }, + resolve: { + aliases: {}, + }, + }, + hooks: { + emit: { + tapAsync: () => {}, + }, + stripesConfigPluginBeforeWrite: { + tap: (str, cb) => cb({ + stripesDeps: { + 'stripes-dependency': [{ + name: 'stripes-dependency', + resolvedPath: '.' + }] + } + }, {}), + }, + contextModuleFactory: { + tap: () => {}, + }, + afterResolve: { + tap: () => {}, + } + } +}; + +describe('The stripes-translations-plugin', function () { + beforeEach(function () { + this.stripesConfig = { + config: {}, + modules: { + '@folio/users': {}, + '@folio/inventory': {}, + '@folio/items': {}, + '@folio/checkout': {}, + }, + }; + }); + + describe('constructor', function () { + it('includes stripes-core with modules for translation', function () { + const sut = new StripesTranslationsPlugin(this.stripesConfig); + expect(sut.modules).to.be.an('object').with.property('@folio/stripes-core'); + expect(sut.modules).to.deep.include(this.stripesConfig.modules); + }); + + it('assigns language filter', function () { + this.stripesConfig.config.languages = ['en']; + const sut = new StripesTranslationsPlugin(this.stripesConfig); + expect(sut.languageFilter).to.be.an('array').and.include('en'); + }); + }); + + describe('apply method', function () { + beforeEach(function () { + this.sandbox.stub(modulePaths, 'locateStripesModule').callsFake((context, mod) => `path/to/${mod}/package.json`); + this.sandbox.stub(fs, 'existsSync').returns(true); + this.sandbox.stub(fs, 'readdirSync').returns(['en.json', 'es.json', 'fr.json']); + this.sandbox.spy(webpack.ContextReplacementPlugin.prototype, 'apply'); + this.sandbox.spy(compilerStub.hooks.emit, 'tapAsync'); + this.sandbox.stub(StripesTranslationsPlugin, 'loadFile').returns({ key1: 'Value 1', key2: 'Value 2' }); + this.compilationStub = { + assets: {}, + }; + }); + + it('registers the "emit" hook', function () { + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + this.sut.apply(compilerStub); + expect(compilerStub.hooks.emit.tapAsync).to.be.calledWith('StripesTranslationsPlugin'); + }); + + it('includes modules from nominated dependencies', function () { + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + this.sut.apply(compilerStub); + expect(this.sut.modules).to.be.an('object').with.property('stripes-dependency'); + }); + + it('generates an emit function with all translations', function () { + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + this.sut.apply(compilerStub); + + // Get the function passed to 'emit' hook + const pluginArgs = compilerStub.hooks.emit.tapAsync.getCall(0).args; + const emitFunction = pluginArgs[1]; + + // Call it and observe the modification to compilation.assets + emitFunction(this.compilationStub, () => {}); + const emitFiles = Object.keys(this.compilationStub.assets); + + expect(emitFiles).to.have.length(3); + expect(emitFiles).to.match(/translations\/en-\d+\.json/); + expect(emitFiles).to.match(/translations\/es-\d+\.json/); + expect(emitFiles).to.match(/translations\/fr-\d+\.json/); + }); + + it('applies ContextReplacementPlugins when language filters are set', function () { + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + this.sut.languageFilter = ['en']; + this.sut.apply(compilerStub); + + expect(webpack.ContextReplacementPlugin.prototype.apply).to.have.been.calledTwice; + expect(webpack.ContextReplacementPlugin.prototype.apply).to.be.calledWith(compilerStub); + }); + }); + + describe('gatherAllTranslations method', function () { + beforeEach(function () { + this.sandbox.stub(modulePaths, 'locateStripesModule').callsFake((context, mod) => `path/to/${mod}/package.json`); + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + this.sandbox.stub(this.sut, 'loadTranslationsDirectory').returns({}); + this.sandbox.stub(this.sut, 'loadTranslationsPackageJson').returns({}); + }); + + it('uses the translation directory when it exists', function () { + this.sandbox.stub(fs, 'existsSync').returns(true); // translation dir exists + this.sut.gatherAllTranslations(); + expect(this.sut.loadTranslationsDirectory).to.have.been.called; + expect(this.sut.loadTranslationsPackageJson).not.to.have.been.called; + }); + + it('uses package.json when translations directory does not exist', function () { + this.sandbox.stub(fs, 'existsSync').returns(false); // translation dir does not exist + this.sut.gatherAllTranslations(); + expect(this.sut.loadTranslationsDirectory).not.to.have.been.called; + expect(this.sut.loadTranslationsPackageJson).to.have.been.called; + }); + }); + + describe('loadTranslationsDirectory method', function () { + beforeEach(function () { + this.sandbox.stub(fs, 'readdirSync').returns(['en.json', 'es.json', 'fr.json']); + this.sandbox.stub(StripesTranslationsPlugin, 'loadFile').returns({ key1: 'Value 1', key2: 'Value 2' }); + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + }); + + it('loads all translations from the translation directory', function () { + const result = this.sut.loadTranslationsDirectory('@folio/my-app', 'path/to/translations'); + expect(StripesTranslationsPlugin.loadFile).to.have.callCount(3); + expect(result).to.be.an('object').with.all.keys('en', 'fr', 'es'); + }); + + it('loads only filtered translations from the translation directory', function () { + this.sut.languageFilter = ['en']; + const result = this.sut.loadTranslationsDirectory('@folio/my-app', 'path/to/translations'); + expect(StripesTranslationsPlugin.loadFile).to.have.been.calledOnce; + expect(result).to.be.an('object').with.all.keys('en').and.not.any.keys('fr', 'es'); + }); + }); + + describe('loadTranslationsPackageJson method', function () { + beforeEach(function () { + this.sandbox.stub(StripesTranslationsPlugin, 'loadFile').returns({ + stripes: { + translations: { + en: { key1: 'Value 1', key2: 'Value 2' }, + es: { key1: 'Value 1', key2: 'Value 2' }, + fr: { key1: 'Value 1', key2: 'Value 2' }, + }, + }, + }); + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + }); + + it('loads all translations from package.json', function () { + const result = this.sut.loadTranslationsPackageJson('@folio/my-app', 'path/to/package.json'); + expect(result).to.be.an('object').with.all.keys('en', 'fr', 'es'); + }); + + it('loads only filtered translations from package.json', function () { + this.sut.languageFilter = ['en']; + const result = this.sut.loadTranslationsPackageJson('@folio/my-app', 'path/to/translations'); + expect(result).to.be.an('object').with.all.keys('en').and.not.any.keys('fr', 'es'); + }); + }); + + describe('getModuleName method', function () { + it('applies "ui-" prefix to module keys', function () { + const result = StripesTranslationsPlugin.getModuleName('@folio/my-app'); + expect(result).to.be.a('string').to.equal('ui-my-app'); + }); + + it('does not apply "ui-" prefix to stripes-core keys', function () { + const result = StripesTranslationsPlugin.getModuleName('@folio/stripes-core'); + expect(result).to.be.a('string').to.equal('stripes-core'); + }); + }); + + describe('prefixModuleKeys method', function () { + it('applies "ui-" prefix to module keys', function () { + const translations = { key1: 'Value 1', key2: 'Value 2', key3: 'Value 3' }; + const result = StripesTranslationsPlugin.prefixModuleKeys('@folio/my-app', translations); + expect(result).to.be.an('object').with.all.keys('ui-my-app.key1', 'ui-my-app.key2', 'ui-my-app.key3'); + }); + + it('does not apply "ui-" prefix to stripes-core keys', function () { + const translations = { key1: 'Value 1', key2: 'Value 2', key3: 'Value 3' }; + const result = StripesTranslationsPlugin.prefixModuleKeys('@folio/stripes-core', translations); + expect(result).to.be.an('object').with.all.keys('stripes-core.key1', 'stripes-core.key2', 'stripes-core.key3'); + }); + }); + + describe('generateFileNames method', function () { + beforeEach(function () { + this.sut = new StripesTranslationsPlugin(this.stripesConfig); + }); + + it('returns paths for emit hook and browser fetch', function () { + const translations = { + en: { key1: 'Value 1', key2: 'Value 2' }, + }; + this.sut.publicPath = '/'; + const result = this.sut.generateFileNames(translations); + expect(result).to.be.an('object').with.property('en').with.property('browserPath').match(/^\/translations\/en-\d+\.json/); + expect(result).to.be.an('object').with.property('en').with.property('emitPath').match(/^translations\/en-\d+\.json/); + }); + + it('applies publicPath', function () { + const translations = { + en: { key1: 'Value 1', key2: 'Value 2' }, + }; + this.sut.publicPath = '/my-public-path/'; + const result = this.sut.generateFileNames(translations); + expect(result).to.be.an('object').with.property('en').with.property('browserPath').match(/^\/my-public-path\/translations\/en-\d+\.json/); + expect(result).to.be.an('object').with.property('en').with.property('emitPath').match(/^translations\/en-\d+\.json/); + }); + }); +}); diff --git a/test/webpack/stripes-webpack-plugin.spec.js b/test/webpack/stripes-webpack-plugin.spec.js new file mode 100644 index 0000000..0e4a7bf --- /dev/null +++ b/test/webpack/stripes-webpack-plugin.spec.js @@ -0,0 +1,79 @@ +const expect = require('chai').expect; + +const StripesWebpackPlugin = require('../../webpack/stripes-webpack-plugin'); +const StripesConfigPlugin = require('../../webpack/stripes-config-plugin'); +const StripesTranslationsPlugin = require('../../webpack/stripes-translations-plugin'); +const StripesBrandingPlugin = require('../../webpack/stripes-branding-plugin'); +const StripesDuplicatesPlugin = require('../../webpack/stripes-duplicate-plugin'); + +const compilerStub = { + apply: () => {}, + plugin: () => {}, + options: { + resolve: { + alias: { + 'my-alias': '/path/to/some-module', + }, + }, + plugins: [], + }, + hooks: { + afterPlugins: { + tap: () => {}, + }, + emit: { + tapAsync: () => {}, + } + }, + context: '/context/path', + warnings: [], +}; + +const mockConfig = { + modules: { + '@folio/users': {}, + '@folio/search': {}, + '@folio/developer': {}, + }, + config: {}, +}; + +describe('The stripes-webpack-plugin', function () { + describe('apply method', function () { + beforeEach(function () { + this.sandbox.stub(StripesConfigPlugin.prototype, 'apply').callsFake(() => {}); + this.sandbox.stub(StripesBrandingPlugin.prototype, 'apply').callsFake(() => {}); + this.sandbox.stub(StripesTranslationsPlugin.prototype, 'apply').callsFake(() => {}); + this.sandbox.stub(StripesDuplicatesPlugin.prototype, 'apply').callsFake(() => {}); + this.sut = new StripesWebpackPlugin({ stripesConfig: mockConfig }); + }); + + afterEach(function () { + delete compilerStub.hooks.stripesConfigPluginBeforeWrite; + }); + + it('applies StripesConfigPlugin', function () { + this.sut.apply(compilerStub); + expect(StripesConfigPlugin.prototype.apply).to.have.been.calledOnce; + expect(StripesConfigPlugin.prototype.apply).to.be.calledWith(compilerStub); + }); + + it('applies StripesBrandingPlugin', function () { + this.sut.apply(compilerStub); + expect(StripesBrandingPlugin.prototype.apply).to.have.been.calledOnce; + expect(StripesBrandingPlugin.prototype.apply).to.be.calledWith(compilerStub); + }); + + it('applies StripesTranslationsPlugin', function () { + this.sut.apply(compilerStub); + expect(StripesTranslationsPlugin.prototype.apply).to.have.been.calledOnce; + expect(StripesTranslationsPlugin.prototype.apply).to.be.calledWith(compilerStub); + }); + + it('applies StripesDuplicatesPlugin', function () { + this.sut.apply(compilerStub); + expect(StripesDuplicatesPlugin.prototype.apply).to.have.been.calledOnce; + expect(StripesDuplicatesPlugin.prototype.apply).to.be.calledWith(compilerStub); + }); + }); +}); diff --git a/test/webpack/test-setup.spec.js b/test/webpack/test-setup.spec.js new file mode 100644 index 0000000..a5b2115 --- /dev/null +++ b/test/webpack/test-setup.spec.js @@ -0,0 +1,20 @@ +// Test setup for webpack tests +// Root-level Mocha hooks defined here apply to all tests regardless of file. + +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); + +before(function () { + chai.use(sinonChai); +}); + +beforeEach(function () { + // The Sinon sandbox allows for easy cleanup of spies and stubs + // ...as of v5, sinon's export is a default sandbox + this.sandbox = sinon; +}); + +afterEach(function () { + this.sandbox.restore(); +}); diff --git a/webpack.config.base.js b/webpack.config.base.js new file mode 100644 index 0000000..d132037 --- /dev/null +++ b/webpack.config.base.js @@ -0,0 +1,77 @@ +// Common Webpack configuration for building Stripes +const fs = require('fs'); +const webpack = require('webpack'); +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const LodashModuleReplacementPlugin = require('lodash-webpack-plugin'); +const { generateStripesAlias } = require('./webpack/module-paths'); +const babelLoaderRule = require('./webpack/babel-loader-rule'); +const typescriptLoaderRule = require('./webpack/typescript-loader-rule'); + +// React doesn't like being included multiple times as can happen when using +// yarn link. Here we find a more specific path to it by first looking in +// stripes-core (__dirname) before falling back to the platform or simply react +const specificReact = generateStripesAlias('react'); + +module.exports = { + entry: [ + '@folio/stripes-components/lib/global.css', + '@folio/stripes-core/src/index', + //path.join(__dirname, 'src', 'index'), + ], + resolve: { + alias: { + 'react': specificReact, + }, + extensions: ['.js', '.json', '.tsx'], + }, + plugins: [ + new HtmlWebpackPlugin({ + template: fs.existsSync('index.html') ? 'index.html' : `${__dirname}/index.html`, + }), + new webpack.EnvironmentPlugin(['NODE_ENV']), + // https://github.com/lodash/lodash-webpack-plugin#feature-sets + // Replace lodash feature sets of modules with noop. + // Feature sets that are truly not needed can be disabled here (listed largest to smallest): + new LodashModuleReplacementPlugin({ + 'shorthands': true, + 'cloning': true, + 'currying': true, + 'caching': true, + 'collections': true, + 'exotics': true, + 'guards': true, + 'metadata': true, // (requires currying) + 'deburring': true, + 'unicode': true, + 'chaining': true, + 'memoizing': true, + 'coercions': true, + 'flattening': true, + 'paths': true, + 'placeholders': true // (requires currying) + }) + ], + module: { + rules: [ + babelLoaderRule, + typescriptLoaderRule, + { + test: /\.(jpg|jpeg|gif|png|ico)$/, + loader: 'file-loader?name=img/[path][name].[hash].[ext]', + }, + { + test: /\.(mp3|m4a)$/, + loader: 'file-loader?name=sound/[name].[hash].[ext]', + }, + { + test: /\.(woff2?)$/, + loader: 'file-loader?name=fonts/[name].[hash].[ext]', + }, + { + test: /\.handlebars$/, + loader: 'handlebars-loader', + }, + ], + }, +}; diff --git a/webpack.config.cli.dev.js b/webpack.config.cli.dev.js new file mode 100644 index 0000000..df3a5d0 --- /dev/null +++ b/webpack.config.cli.dev.js @@ -0,0 +1,80 @@ +// Top level Webpack configuration for running a development environment +// from the command line via devServer.js + +const path = require('path'); +const webpack = require('webpack'); +const postCssImport = require('postcss-import'); +const autoprefixer = require('autoprefixer'); +const postCssCustomProperties = require('postcss-custom-properties'); +const postCssCalc = require('postcss-calc'); +const postCssNesting = require('postcss-nesting'); +const postCssCustomMedia = require('postcss-custom-media'); +const postCssMediaMinMax = require('postcss-media-minmax'); +const postCssColorFunction = require('postcss-color-function'); +const { generateStripesAlias } = require('./webpack/module-paths'); + +const base = require('./webpack.config.base'); +const cli = require('./webpack.config.cli'); + +const devConfig = Object.assign({}, base, cli, { + devtool: 'inline-source-map', + mode: 'development', +}); + +// Override filename to remove the hash in development due to memory issues (STCOR-296) +devConfig.output.filename = 'bundle.js'; + +devConfig.entry.unshift('webpack-hot-middleware/client'); + +devConfig.plugins = devConfig.plugins.concat([ + new webpack.HotModuleReplacementPlugin(), +]); + +// This alias avoids a console warning for react-dom patch +devConfig.resolve.alias['react-dom'] = '@hot-loader/react-dom'; + +devConfig.module.rules.push({ + test: /\.css$/, + use: [ + { + loader: 'style-loader' + }, + { + loader: 'css-loader', + options: { + localIdentName: '[local]---[hash:base64:5]', + modules: true, + sourceMap: true, + importLoaders: 1, + }, + }, + { + loader: 'postcss-loader', + options: { + plugins: () => [ + postCssImport(), + autoprefixer(), + postCssCustomProperties({ + preserve: false, + importFrom: [path.join(generateStripesAlias('@folio/stripes-components'), 'lib/variables.css')] + }), + postCssCalc(), + postCssNesting(), + postCssCustomMedia(), + postCssMediaMinMax(), + postCssColorFunction(), + ], + sourceMap: true, + }, + }, + ], +}); + +devConfig.module.rules.push( + { + test: /\.svg$/, + use: [{ loader: 'file-loader?name=img/[path][name].[hash].[ext]' }] + }, +); + +module.exports = devConfig; diff --git a/webpack.config.cli.js b/webpack.config.cli.js new file mode 100644 index 0000000..cea68cd --- /dev/null +++ b/webpack.config.cli.js @@ -0,0 +1,13 @@ +// Base Webpack configuration for building Stripes at the command line, +// including Stripes configuration. + +const path = require('path'); + +module.exports = { + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.[hash].js', + chunkFilename: 'chunk.[chunkhash].js', + publicPath: '/', + }, +}; diff --git a/webpack.config.cli.prod.js b/webpack.config.cli.prod.js new file mode 100644 index 0000000..a3d3667 --- /dev/null +++ b/webpack.config.cli.prod.js @@ -0,0 +1,93 @@ +// Top level Webpack configuration for building static files for +// production deployment from the command line + +const path = require('path'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); +const postCssImport = require('postcss-import'); +const autoprefixer = require('autoprefixer'); +const postCssCustomProperties = require('postcss-custom-properties'); +const postCssCalc = require('postcss-calc'); +const postCssNesting = require('postcss-nesting'); +const postCssCustomMedia = require('postcss-custom-media'); +const postCssMediaMinMax = require('postcss-media-minmax'); +const postCssColorFunction = require('postcss-color-function'); +const { generateStripesAlias } = require('./webpack/module-paths'); + +const base = require('./webpack.config.base'); +const cli = require('./webpack.config.cli'); + +const prodConfig = Object.assign({}, base, cli, { + mode: 'production', +}); + +prodConfig.plugins = prodConfig.plugins.concat([ + new MiniCssExtractPlugin({ filename: 'style.[contenthash].css', allChunks: true }), + new OptimizeCssAssetsPlugin(), +]); + +prodConfig.module.rules.push({ + test: /\.css$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + { + loader: 'css-loader', + options: { + localIdentName: '[local]---[hash:base64:5]', + modules: true, + importLoaders: 1, + }, + }, + { + loader: 'postcss-loader', + options: { + ident: 'postcss', + plugins: () => [ + postCssImport(), + autoprefixer(), + postCssCustomProperties({ + preserve: false, + importFrom: [path.join(generateStripesAlias('@folio/stripes-components'), 'lib/variables.css')] + }), + postCssCalc(), + postCssNesting(), + postCssCustomMedia(), + postCssMediaMinMax(), + postCssColorFunction(), + ], + }, + }, + ], +}); + +prodConfig.module.rules.push( + { + test: /\.svg$/, + use: [ + { loader: 'file-loader?name=img/[path][name].[hash].[ext]' }, + { + loader: 'svgo-loader', + options: { + plugins: [ + { removeTitle: true }, + { convertColors: { shorthex: false } }, + { convertPathData: false } + ] + } + } + ] + }, +); + +// Remove all data-test or data-test-* attributes +const babelLoaderConfig = prodConfig.module.rules.find(rule => rule.loader === 'babel-loader'); + +babelLoaderConfig.options.plugins = (babelLoaderConfig.options.plugins || []).concat([ + [require.resolve('babel-plugin-remove-jsx-attributes'), { + patterns: ['^data-test.*$'] + }] +]); + +module.exports = prodConfig; diff --git a/webpack/.eslintrc b/webpack/.eslintrc new file mode 100644 index 0000000..d5ba8f9 --- /dev/null +++ b/webpack/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "no-console": "off" + } +} diff --git a/webpack/apply-webpack-overrides.js b/webpack/apply-webpack-overrides.js new file mode 100644 index 0000000..0553695 --- /dev/null +++ b/webpack/apply-webpack-overrides.js @@ -0,0 +1,21 @@ +const logger = require('./logger')(); + +// Applies overrides to the webpack configuration +// Supports a function or an array of functions + +module.exports = function applyWebpackOverrides(overrides, originalConfig) { + logger.log('applying webpack overrides...'); + let config = originalConfig; + + if (overrides && typeof overrides === 'function') { + config = overrides(config); + } else if (overrides && Array.isArray(overrides)) { + for (let i = 0; i < overrides.length; i += 1) { + if (overrides[i] && typeof overrides[i] === 'function') { + config = overrides[i](config); + } + } + } + + return config; +}; diff --git a/webpack/babel-loader-rule.js b/webpack/babel-loader-rule.js new file mode 100644 index 0000000..79ec36b --- /dev/null +++ b/webpack/babel-loader-rule.js @@ -0,0 +1,47 @@ +const path = require('path'); + +// These modules are already transpiled and should be excluded +const folioScopeBlacklist = [ + 'react-githubish-mentions', +].map(segment => path.join('@folio', segment)); + +// We want to transpile files inside node_modules/@folio or outside +// any node_modules directory. And definitely not files in +// node_modules outside the @folio namespace even if some parent +// directory happens to be in @folio. +// +// fn is the path after all symlinks are resolved so we need to be +// wary of all the edge cases yarn link will find for us. +function babelLoaderTest(fileName) { + const nodeModIdx = fileName.lastIndexOf('node_modules'); + if (fileName.endsWith('.js') + && (nodeModIdx === -1 || fileName.lastIndexOf('@folio') > nodeModIdx) + && (folioScopeBlacklist.findIndex(ignore => fileName.includes(ignore)) === -1)) { + return true; + } + return false; +} + +module.exports = { + test: babelLoaderTest, + loader: 'babel-loader', + options: { + cacheDirectory: true, + presets: [ + ['@babel/preset-env', { targets: '> 0.25%, not dead' }], + ['@babel/preset-flow', { all: true }], + ['@babel/preset-react'], + ['@babel/preset-typescript'], + ], + plugins: [ + ['@babel/plugin-proposal-decorators', { 'legacy': true }], + ['@babel/plugin-proposal-class-properties', { 'loose': true }], + '@babel/plugin-proposal-export-namespace-from', + '@babel/plugin-proposal-function-sent', + '@babel/plugin-proposal-numeric-separator', + '@babel/plugin-proposal-throw-expressions', + '@babel/plugin-syntax-import-meta', + [require.resolve('react-hot-loader/babel')], + ] + }, +}; diff --git a/webpack/build.js b/webpack/build.js new file mode 100644 index 0000000..e8aa099 --- /dev/null +++ b/webpack/build.js @@ -0,0 +1,80 @@ +const webpack = require('webpack'); +const path = require('path'); +const StripesWebpackPlugin = require('./stripes-webpack-plugin'); +const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin'); +const applyWebpackOverrides = require('./apply-webpack-overrides'); +const logger = require('./logger')(); + +const platformModulePath = path.join(path.resolve(), 'node_modules'); + +module.exports = function build(stripesConfig, options) { + return new Promise((resolve, reject) => { + logger.log('starting build...'); + let config = require('../webpack.config.cli.prod'); // eslint-disable-line global-require + + if (!options.skipStripesBuild) { + config.plugins.push(new StripesWebpackPlugin({ stripesConfig, createDll: options.createDll })); + } + + config.resolve.modules = ['node_modules', platformModulePath]; + config.resolveLoader = { modules: ['node_modules', platformModulePath] }; + + if (options.outputPath) { + config.output.path = path.resolve(options.outputPath); + } + if (options.publicPath) { + config.output.publicPath = options.publicPath; + } + if (options.sourcemap) { + config.devtool = 'source-map'; + } + if (options.createDll && options.dllName) { // Adjust build to create Webpack DLL + config.entry = {}; + config.entry[options.dllName] = options.createDll.split(','); + config.output.library = '[name]'; + config.output.filename = '[name].[hash].js'; + config.plugins.push(new webpack.DllPlugin({ + name: '[name]', + path: path.join(options.outputPath, '[name].json'), + })); + } + if (options.useDll) { // Consume Webpack DLL + const dependencies = options.useDll.split(','); + const dllPaths = []; + + for (const dependency of dependencies) { + const dependencyPath = path.resolve(dependency); + config.plugins.push(new webpack.DllReferencePlugin({ + context: path.resolve(), + manifest: require(dependencyPath) + })); + + const dllPath = path.dirname(dependencyPath); + + dllPaths.push({ filepath: `${dllPath}/*.js` }); + } + + config.plugins.push(new AddAssetHtmlPlugin(dllPaths)); + } + + // By default, Webpack's production mode will configure UglifyJS + // Override this when we explicity set --no-minify on the command line + if (options.minify === false) { + config.optimization = config.optimization || {}; + config.optimization.minimize = false; + } + + // Give the caller a chance to apply their own webpack overrides + config = applyWebpackOverrides(options.webpackOverrides, config); + + logger.log('assign final webpack config', config); + const compiler = webpack(config); + compiler.run((err, stats) => { + if (err) { + reject(err); + } else { + resolve(stats); + } + }); + }); +}; diff --git a/webpack/logger.js b/webpack/logger.js new file mode 100644 index 0000000..dcc3c18 --- /dev/null +++ b/webpack/logger.js @@ -0,0 +1,11 @@ +const debug = require('debug'); + +// Wrapper for debug to ensure consistent use of namespace +module.exports = function getLogger(name) { + const namespace = name ? `stripes-core:${name}` : 'stripes-core'; + const logger = debug(namespace); + + return { + log: (...args) => logger(...args), + }; +}; diff --git a/webpack/module-paths.js b/webpack/module-paths.js new file mode 100644 index 0000000..5efa659 --- /dev/null +++ b/webpack/module-paths.js @@ -0,0 +1,88 @@ +const path = require('path'); +const logger = require('./logger')(); +const StripesBuildError = require('./stripes-build-error'); + +function tryResolve(modulePath, options) { + try { + return require.resolve(modulePath, options); + } catch (e) { + return false; + } +} + +// Generates a resolvable alias for a module with preference given to the +// workspace's version followed by stripes-core's version followed by the platform's, if available +function generateStripesAlias(moduleName) { + let alias; + const workspaceModule = path.join(path.resolve(), '..', 'node_modules', moduleName); + const platformModule = path.join(path.resolve(), 'node_modules', moduleName); + const coreModule = path.join(__dirname, '..', 'node_modules', moduleName); + + if (tryResolve(workspaceModule)) { + alias = workspaceModule; + } else if (tryResolve(platformModule)) { + alias = platformModule; + } else if (tryResolve(coreModule)) { + alias = coreModule; + } else { + throw new StripesBuildError(`generateStripesAlias: Unable to locate a resolvable alias for ${moduleName} module`); + } + return alias; +} + +// Common logic to locate a stripes module (pass 'package.json') or file within +function locateStripesModule(context, moduleName, alias, ...segments) { + logger.log(`locating stripes module ${moduleName}...`); + let foundPath = false; + + const tryPaths = [ + { + // The place we normally expect to find this module + request: path.join(context, 'node_modules', moduleName, ...segments), + }, { + // The above resolution is overspecific and prevents some use cases eg. yarn workspaces + request: path.join(moduleName, ...segments), + }, { + // This better incorporates the context path but requires nodejs 9+ + request: path.join(moduleName, ...segments), + options: { paths: [context] }, + }, { + // Yarn workspaces fallback: Try node_modules of the parent directory + request: path.join(context, '..', 'node_modules', moduleName, ...segments), + }, + ]; + + // If we are looking for any stripes-* modules, we should also check within the framework's node_modules + if (moduleName.startsWith('@folio/stripes')) { + tryPaths.unshift({ + request: path.join(context, 'node_modules', '@folio', 'stripes', 'node_modules', moduleName, ...segments), + }, { + // Yarn workspace fallback + request: path.join(context, '..', 'node_modules', '@folio', 'stripes', 'node_modules', moduleName, ...segments), + }); + } + + // When available, try for the alias first + if (alias[moduleName]) { + tryPaths.unshift({ + request: path.join(alias[moduleName], ...segments), + }); + } + + for (let i = 0; i < tryPaths.length; i += 1) { + foundPath = tryResolve(tryPaths[i].request, tryPaths[i].options); + if (foundPath) { + break; + } + } + if (foundPath) { + logger.log('found', foundPath); + } + return foundPath; +} + +module.exports = { + tryResolve, + generateStripesAlias, + locateStripesModule, +}; diff --git a/webpack/serve.js b/webpack/serve.js new file mode 100644 index 0000000..7183a0d --- /dev/null +++ b/webpack/serve.js @@ -0,0 +1,91 @@ +const webpack = require('webpack'); +const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); +const path = require('path'); +const nodeObjectHash = require('node-object-hash'); +const express = require('express'); +const webpackDevMiddleware = require('webpack-dev-middleware'); +const webpackHotMiddleware = require('webpack-hot-middleware'); +const connectHistoryApiFallback = require('connect-history-api-fallback'); +const StripesWebpackPlugin = require('./stripes-webpack-plugin'); +const applyWebpackOverrides = require('./apply-webpack-overrides'); +const logger = require('./logger')(); + +const cwd = path.resolve(); +const platformModulePath = path.join(cwd, 'node_modules'); +const coreModulePath = path.join(__dirname, '..', 'node_modules'); +const serverRoot = path.join(__dirname, '..'); + +const cachePlugin = new HardSourceWebpackPlugin({ + cacheDirectory: path.join(cwd, 'webpackcache'), + recordsPath: path.join(cwd, 'webpackcache/records.json'), + configHash(webpackConfig) { + // Build a string value used by HardSource to determine which cache to + // use if [confighash] is in cacheDirectory or if the cache should be + // replaced if [confighash] does not appear in cacheDirectory. + return nodeObjectHash().hash(webpackConfig); + }, +}); + +module.exports = function serve(stripesConfig, options) { + if (typeof stripesConfig.okapi !== 'object') throw new Error('Missing Okapi config'); + if (typeof stripesConfig.okapi.url !== 'string') throw new Error('Missing Okapi URL'); + if (stripesConfig.okapi.url.endsWith('/')) throw new Error('Trailing slash in Okapi URL will prevent Stripes from functioning'); + return new Promise((resolve) => { + logger.log('starting serve...'); + const app = express(); + let config = require('../webpack.config.cli.dev'); // eslint-disable-line global-require + + config.plugins.push(new StripesWebpackPlugin({ stripesConfig })); + + // Look for modules in node_modules, then the platform, then stripes-core + config.resolve.modules = ['node_modules', platformModulePath, coreModulePath]; + config.resolveLoader = { modules: ['node_modules', platformModulePath, coreModulePath] }; + + if (options.cache) { + config.plugins.push(cachePlugin); + } + if (options.devtool) { + config.devtool = options.devtool; + } + // Give the caller a chance to apply their own webpack overrides + config = applyWebpackOverrides(options.webpackOverrides, config); + + logger.log('assign final webpack config', config); + const compiler = webpack(config); + compiler.hooks.done.tap('StripesCoreServe', stats => resolve(stats)); + + const port = options.port || process.env.STRIPES_PORT || 3000; + const host = options.host || process.env.STRIPES_HOST || 'localhost'; + + const staticFileMiddleware = express.static(`${serverRoot}/public`); + + app.use(staticFileMiddleware); + + // Process index rewrite before webpack-dev-middleware + // to respond with webpack's dist copy of index.html + app.use(connectHistoryApiFallback({ + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + })); + + // To handle rewrites without the dot rule, we should include the static middleware twice + // https://github.com/bripkens/connect-history-api-fallback/blob/master/examples/static-files-and-index-rewrite + app.use(staticFileMiddleware); + + app.use(webpackDevMiddleware(compiler, { + logLevel: 'warn', + stats: 'minimal', + publicPath: config.output.publicPath, + })); + + app.use(webpackHotMiddleware(compiler)); + + app.listen(port, host, (err) => { + if (err) { + console.log(err); + return; + } + console.log(`Listening at http://${host}:${port}`); + }); + }); +}; diff --git a/webpack/stripes-branding-plugin.js b/webpack/stripes-branding-plugin.js new file mode 100644 index 0000000..0af7e7f --- /dev/null +++ b/webpack/stripes-branding-plugin.js @@ -0,0 +1,70 @@ +// This webpack plugin generates a virtual module containing the stripes tenant branding configuration +// The virtual module contains require()'s needed for webpack to pull images into the bundle. + +const path = require('path'); +const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); +const defaultBranding = require('../default-assets/branding'); +const logger = require('./logger')('stripesBrandingPlugin'); + +// Minimal favicon settings for favicons-webpack-plugin +const standardFaviconsOnly = { + android: false, + appleIcon: false, + appleStartup: false, + coast: false, + favicons: true, + firefox: false, + windows: false, + yandex: false, +}; + +// Complete favicon settings for favicons-webpack-plugin +const allFavicons = { + android: true, + appleIcon: true, + appleStartup: true, + coast: true, + favicons: true, + firefox: true, + windows: true, + yandex: true, +}; + +module.exports = class StripesBrandingPlugin { + constructor(options) { + logger.log('initializing...'); + // TODO: Validate incoming tenantBranding paths + const tenantBranding = (options && options.tenantBranding) ? options.tenantBranding : {}; + this.branding = Object.assign({}, defaultBranding, tenantBranding); + this.buildAllFavicons = options && options.buildAllFavicons; + } + + apply(compiler) { + // favicons-webpack-plugin will inject the necessary html via HtmlWebpackPlugin + const faviconOptions = this._getFaviconOptions(); + new FaviconsWebpackPlugin(faviconOptions).apply(compiler); + + // Hook into stripesConfigPlugin to supply branding config + compiler.hooks.stripesConfigPluginBeforeWrite.tap('StripesBrandingPlugin', (config) => { + config.branding = this.branding; + logger.log('stripesConfigPluginBeforeWrite', config.branding); + }); + } + + _getFaviconOptions() { + const faviconOptions = { + logo: StripesBrandingPlugin._initFavicon(this.branding.favicon.src), + icons: this.buildAllFavicons ? allFavicons : standardFaviconsOnly, + }; + logger.log('favicon options', faviconOptions); + return faviconOptions; + } + + // Prep favicon path for use with HtmlWebpackPlugin + static _initFavicon(favicon) { + if (path.isAbsolute(favicon)) { + return favicon; + } + return path.join(path.resolve(), favicon); + } +}; diff --git a/webpack/stripes-build-error.js b/webpack/stripes-build-error.js new file mode 100644 index 0000000..8156ae7 --- /dev/null +++ b/webpack/stripes-build-error.js @@ -0,0 +1,7 @@ +module.exports = class StripesBuildError extends Error { + constructor(...args) { + super(...args); + this.name = this.constructor.name; + Error.captureStackTrace(this, StripesBuildError); + } +}; diff --git a/webpack/stripes-config-plugin.js b/webpack/stripes-config-plugin.js new file mode 100644 index 0000000..575d941 --- /dev/null +++ b/webpack/stripes-config-plugin.js @@ -0,0 +1,82 @@ +// This webpack plugin generates a virtual module containing the stripes configuration +// To access this configuration simply import 'stripes-config' within your JavaScript: +// import { okapi, config, modules } from 'stripes-config'; +// +// NOTE: If importing module data for UI purposes such as displaying +// the module name, DO NOT import this module. +// Instead, use the ModulesContext directly or via the withModules/withModule HOCs. + +const _ = require('lodash'); +const VirtualModulesPlugin = require('webpack-virtual-modules'); +const serialize = require('serialize-javascript'); +const { SyncHook } = require('tapable'); +const stripesModuleParser = require('./stripes-module-parser'); +const StripesBuildError = require('./stripes-build-error'); +const stripesSerialize = require('./stripes-serialize'); +const logger = require('./logger')('stripesConfigPlugin'); + +module.exports = class StripesConfigPlugin { + constructor(options) { + logger.log('initializing...'); + if (!_.isObject(options.modules)) { + throw new StripesBuildError('stripes-config-plugin was not provided a "modules" object for enabling stripes modules'); + } + this.options = _.omit(options, 'branding'); + } + + apply(compiler) { + const enabledModules = this.options.modules; + logger.log('enabled modules:', enabledModules); + const { config, metadata, icons, stripesDeps, warnings } = stripesModuleParser.parseAllModules(enabledModules, compiler.context, compiler.options.resolve.alias); + this.mergedConfig = Object.assign({}, this.options, { modules: config }); + this.metadata = metadata; + this.icons = icons; + this.warnings = warnings; + // Prep the virtual module now, we will write to it when ready + this.virtualModule = new VirtualModulesPlugin(); + this.virtualModule.apply(compiler); + + // Establish hook for other plugins to update the config, providing existing config as context + if (compiler.hooks.stripesConfigPluginBeforeWrite) { + throw new StripesBuildError('StripesConfigPlugin hook already in use'); + } + compiler.hooks.stripesConfigPluginBeforeWrite = new SyncHook(['config']); + compiler.hooks.stripesConfigPluginBeforeWrite.tap( + { name: 'StripesConfigPlugin', context: true }, + context => Object.assign(context, { config, metadata, icons, stripesDeps, warnings }) + ); + + // Wait until after other plugins to generate virtual stripes-config + compiler.hooks.afterPlugins.tap('StripesConfigPlugin', (theCompiler) => this.afterPlugins(theCompiler)); + compiler.hooks.emit.tapAsync('StripesConfigPlugin', (compilation, callback) => this.processWarnings(compilation, callback)); + } + + afterPlugins(compiler) { + // Data provided by other stripes plugins via hooks + const pluginData = { + branding: {}, + translations: {}, + }; + compiler.hooks.stripesConfigPluginBeforeWrite.call(pluginData); + + // Create a virtual module for Webpack to include in the build + const stripesVirtualModule = ` + const { okapi, config, modules } = ${serialize(this.mergedConfig, { space: 2 })}; + const branding = ${stripesSerialize.serializeWithRequire(pluginData.branding)}; + const translations = ${serialize(pluginData.translations, { space: 2 })}; + const metadata = ${stripesSerialize.serializeWithRequire(this.metadata)}; + const icons = ${stripesSerialize.serializeWithRequire(this.icons)}; + export { okapi, config, modules, branding, translations, metadata, icons }; + `; + + logger.log('writing virtual module...', stripesVirtualModule); + this.virtualModule.writeModule('node_modules/stripes-config.js', stripesVirtualModule); + } + + processWarnings(compilation, callback) { + if (this.warnings.length) { + compilation.warnings.push(new StripesBuildError(`stripes-config-plugin:\n ${this.warnings.join('\n ')}`)); + } + callback(); + } +}; diff --git a/webpack/stripes-duplicate-plugin.js b/webpack/stripes-duplicate-plugin.js new file mode 100644 index 0000000..a93544f --- /dev/null +++ b/webpack/stripes-duplicate-plugin.js @@ -0,0 +1,43 @@ +// This wrapper around the duplicate-package-checker-webpack-plugin is configured to: +// Error on specific packages that we never want duplicates of, notably react +// Warn on any duplicates that are not yet explicitly ignored + +const DuplicatePackageCheckerPlugin = require('duplicate-package-checker-webpack-plugin'); + +// Module names that must not have duplicates +const duplicatesNotAllowed = [ + 'react', + 'react-dom', + 'react-intl', + 'react-router', + 'react-router-dom', + 'rxjs', + 'stripes', + 'stripes-core', + 'stripes-components', + 'stripes-connect', + 'stripes-final-form', + 'stripes-form', + 'stripes-smart-components', +]; + +module.exports = class StripesDuplicatePlugin { + constructor(options) { + this.config = options.config || {}; + } + + apply(compiler) { + // This will surface duplicates as warnings if configured to + if (this.config.warnAboutAllDuplicatePackages) { + new DuplicatePackageCheckerPlugin().apply(compiler); + } + + + // This will error when duplicates of specific modules are found + new DuplicatePackageCheckerPlugin({ + exclude: instance => !duplicatesNotAllowed.includes(instance.name), + verbose: true, + emitError: true, + }).apply(compiler); + } +}; diff --git a/webpack/stripes-module-parser.js b/webpack/stripes-module-parser.js new file mode 100644 index 0000000..3f532b4 --- /dev/null +++ b/webpack/stripes-module-parser.js @@ -0,0 +1,277 @@ +const path = require('path'); +const _ = require('lodash'); +const semver = require('semver'); +const modulePaths = require('./module-paths'); +const StripesBuildError = require('./stripes-build-error'); +const logger = require('./logger')('stripesModuleParser'); + +// These config keys do not get exported with type-specific config +const TOP_LEVEL_ONLY = ['permissions', 'icons', 'permissionSets', 'actsAs', 'type', 'hasSettings']; + +function appendOrSingleton(maybeArray, newValue) { + const singleton = [newValue]; + if (Array.isArray(maybeArray)) return maybeArray.concat(singleton); + return singleton; +} +// Construct and validate expected icon file paths +function buildIconFilePaths(name, root, module, warnings) { + const iconDir = 'icons'; + const defaultKey = 'high'; + const iconVariants = { + high: `${name}.svg`, + low: `${name}.png`, + // bw: `${name}-bw.png`, example + }; + + return _.reduce(iconVariants, (iconPaths, file, key) => { + const iconFilePath = path.join(root, iconDir, file); + const isFound = modulePaths.tryResolve(iconFilePath); + if (!isFound) { + warnings.push(`Module ${module} defines icon "${name}" but is missing file "${iconDir}/${file}"`); + } + iconPaths[key] = { + src: isFound ? iconFilePath : '', + }; + if (key === defaultKey) { + iconPaths.src = isFound ? iconFilePath : ''; + } + return iconPaths; + }, {}); +} + +function iconPropsFromConfig(icon) { + return { + alt: icon.alt, + title: icon.title, + }; +} + + +// Handles the parsing of one Stripes module's configuration and metadata +class StripesModuleParser { + constructor(moduleName, overrideConfig, context, aliases) { + logger.log(`initializing parser for ${moduleName}...`); + this.moduleName = moduleName; + this.modulePath = ''; + this.nameOnly = moduleName.replace(/.*\//, ''); + this.overrideConfig = overrideConfig; + this.packageJson = this.loadModulePackageJson(context, aliases); + this.warnings = []; + } + + // Loads a given module's package.json and errors when it fails + // By using require, the JSON will already be parsed + loadModulePackageJson(context, aliases) { + const packageJsonFile = modulePaths.locateStripesModule(context, this.moduleName, aliases, 'package.json'); + if (!packageJsonFile) { + throw new StripesBuildError(`StripesModuleParser: Unable to locate ${this.moduleName}'s package.json`); + } + this.modulePath = packageJsonFile.replace('package.json', ''); + // eslint-disable-next-line global-require,import/no-dynamic-require + return require(packageJsonFile); + } + + // Wrapper to source data and transform into collections (config and metadata) + parseModule() { + const stripes = this.packageJson.stripes; + if (!_.isObject(stripes)) { + throw new StripesBuildError(`Included module ${this.moduleName} does not have a "stripes" key in package.json`); + } + + // Upgrade string actsAs to singleton array and provide compatibility for deprecated options + let actsAs = stripes.actsAs; + if (!Array.isArray(actsAs)) { + if (typeof actsAs === 'string') actsAs = [actsAs]; + else if (typeof stripes.type === 'string') { + this.warnings.push(`Module ${this.moduleName} uses deprecated "type" property. Prefer "actsAs".`); + actsAs = [stripes.type]; + if (stripes.hasSettings && stripes.type !== 'settings') { + this.warnings.push(`Module ${this.moduleName} uses deprecated "hasSettings" property. Instead, add "settings" to the "actsAs" array and render your settings component when your main component is passed the prop 'actAs="settings"'.`); + actsAs.push('settings'); + } + if (stripes.handlerName) { + this.warnings.push(`Module ${this.moduleName} uses deprecated "handlerName" property. Instead, add "handler" to the "actsAs" array and render your handler component when your main component is passed the prop 'actAs="handler"'.`); + actsAs.push('settings'); + } + } else { + throw new StripesBuildError(`Included module ${this.moduleName} does not specify stripes.actsAs in package.json`); + } + } + + return { + name: this.nameOnly, + actsAs, + config: this.config || this.parseStripesConfig(this.moduleName, this.packageJson), + metadata: this.metadata || this.parseStripesMetadata(this.packageJson), + }; + } + + // Validates and parses a module's stripes data + // One critical aspect of this operation is value of getModule, which is the entry point into each app + // This will be modified to a webpack-compatible dynamic import when we implement code-splitting. + parseStripesConfig(moduleName, packageJson) { + const { stripes, description, version } = packageJson; + + const stripeConfig = _.omit(Object.assign({}, stripes, this.overrideConfig, { + module: moduleName, + getModule: new Function([], `return require('${moduleName}').default;`), // eslint-disable-line no-new-func + description, + version, + }), TOP_LEVEL_ONLY); + logger.log('config:', stripeConfig); + return stripeConfig; + } + + // Extract metadata defined here: + // https://github.com/folio-org/stripes-core/blob/master/doc/app-metadata.md + parseStripesMetadata(packageJson) { + const icons = this.getIconMetadata(packageJson.stripes.icons, packageJson.stripes.type === 'app'); + const welcomePageEntries = this.getWelcomePageEntries(packageJson.stripes.welcomePageEntries, icons); + + const metadata = { + name: this.nameOnly, + version: packageJson.version, + description: packageJson.description, + license: packageJson.license, + feedback: packageJson.bugs, + type: packageJson.stripes.type, + shortTitle: packageJson.stripes.displayName, + fullTitle: packageJson.stripes.fullName, + defaultPopoverSize: packageJson.stripes.defaultPopoverSize, + defaultPreviewWidth: packageJson.stripes.defaultPreviewWidth, + helpPage: packageJson.stripes.helpPage, + icons, + welcomePageEntries, + }; + logger.log('metadata:', metadata); + return metadata; + } + + getIconMetadata(icons, isApp) { + if (!icons || !Array.isArray(icons)) { + if (isApp) this.warnings.push(`Module ${this.moduleName} has no icons defined in stripes.icons`); + return {}; + } + return _.reduce(icons, (iconMetadata, icon) => { + iconMetadata[icon.name] = iconPropsFromConfig(icon); + // The icon's name will be used in the event fileName is not specified + Object.assign(iconMetadata[icon.name], this.buildIconFilePaths(icon.fileName || icon.name)); + return iconMetadata; + }, {}); + } + + // Construct and validate expected icon file paths + buildIconFilePaths(name) { + return buildIconFilePaths(name, this.modulePath, this.moduleName, this.warnings); + } + + getWelcomePageEntries(entries, icons) { + if (!entries || !Array.isArray(entries)) { + return []; + } + return _.map(entries, (entry) => { + if (!icons[entry.iconName]) { + this.warnings.push(`Module ${this.moduleName} defines welcome page entry icon "${entry.iconName}" with no matching stripes.icons definition`); + } + return { + iconName: entry.iconName, + headline: entry.headline, + description: entry.description, + }; + }); + } +} + +// The helper loops over a tenant's enabled modules and parses each module +// The resulting config is grouped by stripes module type (app, settings, plugin, etc.) +// The metadata is grouped by module name +function parseAllModules(enabledModules, context, aliases) { + const allModuleConfigs = { + app: [], + }; + const allMetadata = {}; + const unsortedStripesDeps = {}; + const icons = {}; + let warnings = []; + + _.forOwn(enabledModules, (overrideConfig, moduleName) => { + const moduleParser = new StripesModuleParser(moduleName, overrideConfig, context, aliases); + const parsedModule = moduleParser.parseModule(); + + // config + parsedModule.actsAs.forEach(type => { + allModuleConfigs[type] = appendOrSingleton(allModuleConfigs[type], parsedModule.config); + }); + + // metadata + allMetadata[parsedModule.name] = parsedModule.metadata; + + // stripesDeps + const config = parsedModule.config; + if (Array.isArray(config.stripesDeps)) { + config.stripesDeps.forEach(dep => { + // locate dep relative to the module that depends on it + const depContext = modulePaths.locateStripesModule(context, config.module, aliases, 'package.json'); + const packageJsonPath = modulePaths.locateStripesModule(depContext, dep, aliases, 'package.json'); + if (!packageJsonPath) { + throw new StripesBuildError(`StripesModuleParser: Unable to locate ${dep}'s package.json (dependency of ${config.module})`); + } + const packageJson = require(packageJsonPath); + const resolvedPath = packageJsonPath.replace('/package.json', ''); + unsortedStripesDeps[dep] = appendOrSingleton(unsortedStripesDeps[dep], { + name: dep, + dependencyOf: config.module, + resolvedPath, + version: packageJson.version, + ...(packageJson.stripes && packageJson.stripes.icons ? { icons: packageJson.stripes.icons } : {}) + }); + }); + } + + // icons + icons[parsedModule.config.module] = parsedModule.metadata.icons; + + // warnings + if (moduleParser.warnings.length) { + warnings = warnings.concat(moduleParser.warnings); + } + }); + + // stripesDeps are then sorted so that resources will be gathered from the newest version where they are available + const stripesDeps = {}; + for (const [key, value] of Object.entries(unsortedStripesDeps)) { + stripesDeps[key] = value.sort((a, b) => semver.compare(a.version, b.version)); + } + + // icons from deps + const anyHasIcon = vers => vers.reduce((acc, dep) => (acc || ('icons' in dep)), false); + const mergeIcons = vers => vers.reduce((depIcons, ver) => { + if ('icons' in ver) { + ver.icons.forEach(icon => { + depIcons[icon.name] = { + ...iconPropsFromConfig(icon), + ...buildIconFilePaths(icon.fileName || icon.name, ver.resolvedPath, ver.name, warnings) + }; + }); + } + return depIcons; + }, {}); + for (const [key, value] of Object.entries(stripesDeps)) { + if (anyHasIcon(value)) { + icons[key] = mergeIcons(value); + } + } + + return { + config: allModuleConfigs, + metadata: allMetadata, + stripesDeps, + icons, + warnings, + }; +} + +module.exports = { + StripesModuleParser, + parseAllModules, +}; diff --git a/webpack/stripes-node-api.js b/webpack/stripes-node-api.js new file mode 100644 index 0000000..2fd1d2a --- /dev/null +++ b/webpack/stripes-node-api.js @@ -0,0 +1,7 @@ +const build = require('./build'); +const serve = require('./serve'); + +module.exports = { + build, + serve, +}; diff --git a/webpack/stripes-serialize.js b/webpack/stripes-serialize.js new file mode 100644 index 0000000..5748db4 --- /dev/null +++ b/webpack/stripes-serialize.js @@ -0,0 +1,33 @@ +const path = require('path'); + +// Serialize an object +// In the process, it wraps "src" properties with requires for loading files +function serializeWithRequire(theObject) { + const assetPath = (thePath) => { + if (path.isAbsolute(thePath)) { + return thePath; + } + + return path.join('..', thePath); // Look outside the node_modules directory + }; + + // Wraps image paths with require()'s for webpack to process via its file loaders + // The require()'s are just strings here so we don't attempt to invoke them right now + const injectRequire = (key, value) => { + if (key === 'src' && typeof value === 'string' && value !== '') { + return `require('${assetPath(value)}')`; + } + + return value; + }; + // Serialize whole configuration, adding require()'s as needed + const theString = JSON.stringify(theObject, injectRequire, 2); + + // Now that the object has been serialized, this regex omits the wrapping quotes + // surrounding the require()'s so they are invoked when Webpack loads stripes-config + return theString.replace(/"(require\([^)]+\))"/g, (match, $1) => $1); +} + +module.exports = { + serializeWithRequire, +}; diff --git a/webpack/stripes-translations-plugin.js b/webpack/stripes-translations-plugin.js new file mode 100644 index 0000000..93d5b79 --- /dev/null +++ b/webpack/stripes-translations-plugin.js @@ -0,0 +1,183 @@ +const path = require('path'); +const fs = require('fs'); +const _ = require('lodash'); +const webpack = require('webpack'); +const modulePaths = require('./module-paths'); +const logger = require('./logger')('stripesTranslationsPlugin'); + +function prefixKeys(obj, prefix) { + const res = {}; + for (const key of Object.keys(obj)) { + res[`${prefix}${key}`] = obj[key]; + } + return res; +} + +module.exports = class StripesTranslationPlugin { + constructor(options) { + // Include stripes-core et al because they have translations + this.modules = { + '@folio/stripes-core': {}, + '@folio/stripes-components': {}, + '@folio/stripes-smart-components': {}, + '@folio/stripes-form': {}, + }; + Object.assign(this.modules, options.modules); + this.languageFilter = options.config.languages || []; + logger.log('language filter', this.languageFilter); + + console.log(this.modules); + } + + apply(compiler) { + // Used to help locate modules + this.context = compiler.context; + this.publicPath = compiler.options.output.publicPath; + this.aliases = compiler.options.resolve.alias; + + // Limit the number of languages loaded by third-party libraries with the ContextReplacementPlugin + if (this.languageFilter.length) { + const filterRegex = new RegExp(`(${this.languageFilter.join('|')})`); // constructed regex will look something like /(en|es)/ + new webpack.ContextReplacementPlugin(/react-intl[/\\]locale-data/, filterRegex).apply(compiler); + new webpack.ContextReplacementPlugin(/moment[/\\]locale/, filterRegex).apply(compiler); + } + + + // Hook into stripesConfigPlugin to supply paths to translation files + // and gather additional modules from stripes.stripesDeps + compiler.hooks.stripesConfigPluginBeforeWrite.tap({ name:'StripesTranslationsPlugin', context: true }, (context, config) => { + // Add stripesDeps + for (const [key, value] of Object.entries(context.stripesDeps)) { + // TODO: merge translations from all versions of stripesDeps + this.modules[key] = value[value.length - 1]; + } + + // Gather all translations available in each module + const allTranslations = this.gatherAllTranslations(); + + const fileData = this.generateFileNames(allTranslations); + const allFiles = _.mapValues(fileData, data => data.browserPath); + + + config.translations = allFiles; + logger.log('stripesConfigPluginBeforeWrite', config.translations); + + // Emit merged translations to the output directory + compiler.hooks.emit.tapAsync('StripesTranslationsPlugin', (compilation, callback) => { + Object.keys(allTranslations).forEach((language) => { + logger.log(`emitting translations for ${language} --> ${fileData[language].emitPath}`); + const content = JSON.stringify(allTranslations[language]); + compilation.assets[fileData[language].emitPath] = { + source: () => content, + size: () => content.length, + }; + }); + callback(); + }); + }); + } + + // Locate each module's translations directory (current) or package.json data (fallback) + gatherAllTranslations() { + const allTranslations = {}; + for (const mod of Object.keys(this.modules)) { + // translations from module dependencies may need to be located relative to their dependent (eg. in yarn workspaces) + const locateContext = this.modules[mod].resolvedPath || this.context; + const modPackageJsonPath = modulePaths.locateStripesModule(locateContext, mod, this.aliases, 'package.json'); + + console.log('modulePaths', modulePaths, modPackageJsonPath); + + if (modPackageJsonPath) { + const moduleName = StripesTranslationPlugin.getModuleName(mod); + const modTranslationDir = modPackageJsonPath.replace('package.json', `translations/${moduleName}`); + if (fs.existsSync(modTranslationDir)) { + _.merge(allTranslations, this.loadTranslationsDirectory(mod, modTranslationDir)); + } else { + const modTranslationDirFallback = modPackageJsonPath.replace('package.json', 'translations'); + if (fs.existsSync(modTranslationDirFallback)) { + logger.log(`cannot find ${modTranslationDir} falling back to ${modTranslationDirFallback}`); + _.merge(allTranslations, this.loadTranslationsDirectory(mod, modTranslationDirFallback)); + } else { + logger.log(`cannot find ${modTranslationDirFallback} falling back to ${modPackageJsonPath}`); + _.merge(allTranslations, this.loadTranslationsPackageJson(mod, modPackageJsonPath)); + } + } + } else { + console.log(`Unable to locate ${mod} while looking for translations.`); + } + } + return allTranslations; + } + + // Load translation *.json files from a single module's translation directory + loadTranslationsDirectory(moduleName, dir) { + logger.log('loading translations from directory', dir); + const moduleTranslations = {}; + + let enTranslations = {}; + const enPath = path.join(dir, 'en.json'); + if (fs.existsSync(enPath)) { + const rawEnTranslations = StripesTranslationPlugin.loadFile(enPath); + enTranslations = StripesTranslationPlugin.prefixModuleKeys(moduleName, rawEnTranslations); + } + + for (const translationFile of fs.readdirSync(dir)) { + const language = translationFile.replace('.json', ''); + // When filter is set, skip other languages. Otherwise loads all + if (!this.languageFilter.length || this.languageFilter.includes(language)) { + const translations = StripesTranslationPlugin.loadFile(path.join(dir, translationFile)); + moduleTranslations[language] = Object.assign({}, enTranslations, StripesTranslationPlugin.prefixModuleKeys(moduleName, translations)); + } + } + return moduleTranslations; + } + + // Maintains backwards-compatibility with existing apps + loadTranslationsPackageJson(moduleName, packageJsonPath) { + logger.log('loading translations from package.json (legacy)', packageJsonPath); + const moduleTranslations = {}; + const packageJson = StripesTranslationPlugin.loadFile(packageJsonPath); + if (packageJson.stripes && packageJson.stripes.translations) { + for (const language of Object.keys(packageJson.stripes.translations)) { + // When filter is set, skip other languages. Otherwise loads all + if (!this.languageFilter.length || this.languageFilter.includes(language)) { + moduleTranslations[language] = StripesTranslationPlugin.prefixModuleKeys(moduleName, packageJson.stripes.translations[language]); + } + } + } + return moduleTranslations; + } + + // Common point for loading and parsing the file facilitates testing + static loadFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + // Could also use require here... + // return require(filePath); // eslint-disable-line global-require, import/no-dynamic-require + } + + static getModuleName(module) { + const name = module.replace(/.*\//, ''); + const moduleName = name.indexOf('stripes-') === 0 ? `${name}` : `ui-${name}`; + return moduleName; + } + + // Converts "example.key" for "@folio/app" into "ui-app.example.key" + static prefixModuleKeys(moduleName, translations) { + const prefix = `${StripesTranslationPlugin.getModuleName(moduleName)}.`; + return prefixKeys(translations, prefix); + } + + // Assign output path names for each to be accessed later by stripes-config-plugin + generateFileNames(allTranslations) { + const files = {}; + const timestamp = Date.now(); // To facilitate cache busting, could also generate a hash + Object.keys(allTranslations).forEach((language) => { + files[language] = { + // Fetching from the browser must take into account public path. The replace regex removes double slashes + browserPath: `${this.publicPath}/translations/${language}-${timestamp}.json`.replace(/\/\//, '/'), + emitPath: `translations/${language}-${timestamp}.json`, + }; + }); + return files; + } +}; diff --git a/webpack/stripes-webpack-plugin.js b/webpack/stripes-webpack-plugin.js new file mode 100644 index 0000000..e439ad3 --- /dev/null +++ b/webpack/stripes-webpack-plugin.js @@ -0,0 +1,35 @@ +// This webpack plugin wraps all other stripes webpack plugins to simplify inclusion within the webpack config + +const StripesConfigPlugin = require('./stripes-config-plugin'); +const StripesBrandingPlugin = require('./stripes-branding-plugin'); +const StripesTranslationsPlugin = require('./stripes-translations-plugin'); +const StripesDuplicatesPlugin = require('./stripes-duplicate-plugin'); +const logger = require('./logger')('stripesWebpackPlugin'); + +module.exports = class StripesWebpackPlugin { + constructor(options) { + this.stripesConfig = options.stripesConfig; + this.createDll = options.createDll; + } + + apply(compiler) { + logger.log('Creating Stripes plugins...'); + const isProduction = compiler.options.mode === 'production'; + + const stripesPlugins = [ + new StripesConfigPlugin(this.stripesConfig), + new StripesTranslationsPlugin(this.stripesConfig), + new StripesDuplicatesPlugin(this.stripesConfig), + ]; + + if (!this.createDll) { + stripesPlugins.push(new StripesBrandingPlugin({ + tenantBranding: this.stripesConfig.branding, + buildAllFavicons: isProduction, + })); + } + + logger.log('Applying Stripes plugins...'); + stripesPlugins.forEach(plugin => plugin.apply(compiler)); + } +}; diff --git a/webpack/tsconfig.json b/webpack/tsconfig.json new file mode 100644 index 0000000..34890c9 --- /dev/null +++ b/webpack/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "jsx": "react", + "lib": [ + "esnext" + ] + }, + "awesomeTypescriptLoaderOptions": { + "useCache": true + } +} diff --git a/webpack/typescript-loader-rule.js b/webpack/typescript-loader-rule.js new file mode 100644 index 0000000..5b81327 --- /dev/null +++ b/webpack/typescript-loader-rule.js @@ -0,0 +1,24 @@ +const path = require('path'); + +// We want to transpile files inside node_modules/@folio or outside +// any node_modules directory. And definitely not files in +// node_modules outside the @folio namespace even if some parent +// directory happens to be in @folio. +function babelLoaderTest(fileName) { + const nodeModIdx = fileName.lastIndexOf('node_modules'); + const folioIdx = fileName.lastIndexOf('@folio'); + + if (fileName.endsWith('.tsx') && (nodeModIdx === -1 || folioIdx > nodeModIdx)) { + return true; + } + + return false; +} + +module.exports = { + test: babelLoaderTest, + loader: 'awesome-typescript-loader', + query: { + configFileName: path.join(__dirname, 'tsconfig.json'), + }, +};