diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md index 29fa3034cae..060beb6ee18 100644 --- a/packages/grafana-toolkit/README.md +++ b/packages/grafana-toolkit/README.md @@ -127,6 +127,27 @@ Currently we support following Jest configuration properties: - [`snapshotSerializers`](https://jest-bot.github.io/jest/docs/configuration.html#snapshotserializers-array-string) - [`moduleNameMapper`](https://jestjs.io/docs/en/configuration#modulenamemapper-object-string-string) +### How can I customize Webpack rules or plugins? +You can provide your own webpack configuration. +Provide a function implementing `CustomWebpackConfigurationGetter` in a file named `webpack.config.ts`. + +You can import the correct interface and Options from `@grafana/toolkit/src/config`. + +Example + +``` ts +import { CustomWebpackConfigurationGetter } from '@grafana/toolkit/src/config' +import CustomPlugin from 'custom-plugin'; + +const getWebpackConfig: CustomWebpackConfigurationGetter = (defaultConfig, options) => { + console.log('Custom config'); + defaultConfig.plugins.push(new CustomPlugin()) + return defaultConfig; +} + +export = getWebpackConfig; +``` + ### How can I style my plugin? We support pure CSS, SASS, and CSS-in-JS approach (via [Emotion](https://emotion.sh/)). diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts index 576b2c74eef..44a92529979 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts @@ -1,7 +1,7 @@ import webpack = require('webpack'); import formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); import clearConsole = require('react-dev-utils/clearConsole'); -import { getWebpackConfig } from '../../../config/webpack.plugin.config'; +import { loadWebpackConfig } from '../../../config/webpack.plugin.config'; export interface PluginBundleOptions { watch: boolean; @@ -12,7 +12,7 @@ export interface PluginBundleOptions { // export const bundlePlugin = useSpinner('Bundle plugin', ({ watch }) => { export const bundlePlugin = async ({ watch, production }: PluginBundleOptions) => { const compiler = webpack( - getWebpackConfig({ + await loadWebpackConfig({ watch, production, }) diff --git a/packages/grafana-toolkit/src/config/index.ts b/packages/grafana-toolkit/src/config/index.ts new file mode 100644 index 00000000000..ccb2e12e1b5 --- /dev/null +++ b/packages/grafana-toolkit/src/config/index.ts @@ -0,0 +1 @@ +export { CustomWebpackConfigurationGetter, WebpackConfigurationOptions } from './webpack.plugin.config'; diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/noOverride/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/noOverride/package.json new file mode 100644 index 00000000000..4efb0bb15e1 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/noOverride/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overrides/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/package.json new file mode 100644 index 00000000000..4efb0bb15e1 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overrides/webpack.config.ts b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/webpack.config.ts new file mode 100644 index 00000000000..035cc825233 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/webpack.config.ts @@ -0,0 +1,10 @@ +import { CustomWebpackConfigurationGetter } from '../../../webpack.plugin.config'; +import _ from 'lodash'; + +const overrideWebpackConfig: CustomWebpackConfigurationGetter = (originalConfig, options) => { + const config = _.cloneDeep(originalConfig); + config.name = 'customConfig'; + return config; +}; + +export = overrideWebpackConfig; diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/package.json new file mode 100644 index 00000000000..4efb0bb15e1 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/webpack.config.ts b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/webpack.config.ts new file mode 100644 index 00000000000..8c02bebde72 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/webpack.config.ts @@ -0,0 +1,8 @@ +import { CustomWebpackConfigurationGetter } from '../../../webpack.plugin.config'; +import _ from 'lodash'; + +export const getWebpackConfig: CustomWebpackConfigurationGetter = (originalConfig, options) => { + const config = _.cloneDeep(originalConfig); + config.name = 'customConfig'; + return config; +}; diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/package.json new file mode 100644 index 00000000000..4efb0bb15e1 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/webpack.config.ts b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/webpack.config.ts new file mode 100644 index 00000000000..5d518eb7abe --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/webpack.config.ts @@ -0,0 +1,7 @@ +/* WRONG CONFIG ON PURPOSE - DO NOT COPY THIS */ + +const config = { + name: 'test', +}; + +export = config; diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts index 733fbb65942..197df1d53e6 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts @@ -1,7 +1,12 @@ -import { findModuleFiles } from './webpack.plugin.config'; -const fs = require('fs'); +import { findModuleFiles, loadWebpackConfig } from './webpack.plugin.config'; +import fs from 'fs'; +import * as webpackConfig from './webpack.plugin.config'; -jest.mock('fs'); +jest.mock('./webpack/loaders', () => ({ + getFileLoaders: () => [], + getStylesheetEntries: () => [], + getStyleLoaders: () => [], +})); const modulePathsMock = [ 'some/path/module.ts', @@ -15,16 +20,56 @@ const modulePathsMock = [ describe('Plugin webpack config', () => { describe('findModuleTs', () => { beforeAll(() => { - fs.statSync.mockReturnValue({ + jest.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false, - }); + } as any); }); - it('finds module.ts and module.tsx files', () => { - const moduleFiles = findModuleFiles('/', modulePathsMock); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('finds module.ts and module.tsx files', async () => { + const moduleFiles = await findModuleFiles('/', modulePathsMock); expect(moduleFiles.length).toBe(2); // normalize windows path - \\ -> / expect(moduleFiles.map(p => p.replace(/\\/g, '/'))).toEqual(['/some/path/module.ts', '/some/path/module.tsx']); }); }); + + describe('loadWebpackConfig', () => { + beforeAll(() => { + jest.spyOn(webpackConfig, 'findModuleFiles').mockReturnValue(new Promise((res, _) => res([]))); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('uses default config if no override exists', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/noOverride/`); + await loadWebpackConfig({}); + }); + + it('calls customConfig if it exists', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/overrides/`); + const config = await loadWebpackConfig({}); + expect(config.name).toBe('customConfig'); + }); + + it('loads export named getWebpackConfiguration', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/overridesNamedExport/`); + const config = await loadWebpackConfig({}); + expect(config.name).toBe('customConfig'); + }); + + it('throws an error if module does not export function', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/unsupportedOverride/`); + await expect(loadWebpackConfig({})).rejects.toThrowError(); + }); + }); }); diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 7acacbce4e3..0540f3186ea 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -1,4 +1,5 @@ const fs = require('fs'); +const util = require('util'); const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const ReplaceInFileWebpackPlugin = require('replace-in-file-webpack-plugin'); @@ -7,24 +8,31 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const readdirPromise = util.promisify(fs.readdir); +const accessPromise = util.promisify(fs.access); + import * as webpack from 'webpack'; import { getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack/loaders'; -interface WebpackConfigurationOptions { +export interface WebpackConfigurationOptions { watch?: boolean; production?: boolean; } -type WebpackConfigurationGetter = (options: WebpackConfigurationOptions) => webpack.Configuration; +type WebpackConfigurationGetter = (options: WebpackConfigurationOptions) => Promise; +export type CustomWebpackConfigurationGetter = ( + originalConfig: webpack.Configuration, + options: WebpackConfigurationOptions +) => webpack.Configuration; -export const findModuleFiles = (base: string, files?: string[], result?: string[]) => { - files = files || fs.readdirSync(base); +export const findModuleFiles = async (base: string, files?: string[], result?: string[]) => { + files = files || (await readdirPromise(base)); result = result || []; if (files) { - files.forEach(file => { + files.forEach(async file => { const newbase = path.join(base, file); if (fs.statSync(newbase).isDirectory()) { - result = findModuleFiles(newbase, fs.readdirSync(newbase), result); + result = await findModuleFiles(newbase, await readdirPromise(newbase), result); } else { const filename = path.basename(file); if (/^module.(t|j)sx?$/.exec(filename)) { @@ -56,9 +64,9 @@ const getManualChunk = (id: string) => { return null; }; -const getEntries = () => { +const getEntries = async () => { const entries: { [key: string]: string } = {}; - const modules = getModuleFiles(); + const modules = await getModuleFiles(); modules.forEach(modFile => { const mod = getManualChunk(modFile); @@ -114,7 +122,7 @@ const getCommonPlugins = (options: WebpackConfigurationOptions) => { ]; }; -export const getWebpackConfig: WebpackConfigurationGetter = options => { +const getBaseWebpackConfig: WebpackConfigurationGetter = async options => { const plugins = getCommonPlugins(options); const optimization: { [key: string]: any } = {}; @@ -134,7 +142,7 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { }, context: path.join(process.cwd(), 'src'), devtool: 'source-map', - entry: getEntries(), + entry: await getEntries(), output: { filename: '[name].js', path: path.join(process.cwd(), 'dist'), @@ -224,3 +232,26 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { optimization, }; }; + +export const loadWebpackConfig: WebpackConfigurationGetter = async options => { + const baseConfig = await getBaseWebpackConfig(options); + const customWebpackPath = path.resolve(process.cwd(), 'webpack.config.ts'); + + try { + await accessPromise(customWebpackPath); + const customConfig = require(customWebpackPath); + const configGetter = customConfig.getWebpackConfig || customConfig; + if (typeof configGetter !== 'function') { + throw Error( + 'Custom webpack config needs to export a function implementing CustomWebpackConfigurationGetter. Function needs to be ' + + 'module export or named "getWebpackConfig"' + ); + } + return (configGetter as CustomWebpackConfigurationGetter)(baseConfig, options); + } catch (err) { + if (err.code === 'ENOENT') { + return baseConfig; + } + throw err; + } +};