diff --git a/README.md b/README.md index 3282ce4..5efc0f1 100755 --- a/README.md +++ b/README.md @@ -277,6 +277,56 @@ With this `{rootDir}/src/ui/tsconfig.json`: - `lightningCssMinimizerOptions` (`(options: LightningCssMinimizerRspackPluginOptions) => LightningCssMinimizerRspackPluginOptions`) - modify or return a custom [LightningCssMinimizerRspackPlugin](https://rspack.dev/plugins/rspack/lightning-css-minimizer-rspack-plugin) - `terser` (`(options: TerserOptions) => TerserOptions`) - modify or return a custom [Terser options](https://github.com/terser/terser#minify-options). +##### CSS Loader configuration + +- `cssLoader` (`Partial`) — allows to override default css-loader settings. All options are optional and will be merged with default values. + +**CssLoaderOptions interface:** + +- `url` (`boolean | {filter: (url: string, resourcePath: string) => boolean}`) — enables/disables `url()`/`image-set()` functions handling. Default: `{filter: (url: string) => !url.startsWith('data:')}` (ignores data URIs) +- `import` (`boolean | {filter: (url: string, media: string, resourcePath: string) => boolean}`) — enables/disables `@import` at-rules handling +- `modules` (`boolean | 'local' | 'global' | 'pure' | 'icss' | CssLoaderModulesOptions`) — enables/disables CSS Modules or ICSS and setup configuration. Default: `{auto: true, localIdentName: '[name]__[local]--[hash:base64:5]', exportLocalsConvention: 'camelCase'}` +- `sourceMap` (`boolean`) — enables/disables source maps. Default: `!disableSourceMapGeneration` +- `esModule` (`boolean`) — use the ES modules syntax +- `exportType` (`'array' | 'string' | 'css-style-sheet'`) — allows exporting styles as array with modules, string or constructable stylesheet + +**CssLoaderModulesOptions interface:** + +- `auto` (`RegExp | ((resourcePath: string) => boolean) | boolean`) — allows auto enable CSS modules based on filename +- `mode` (`'local' | 'global' | 'pure' | 'icss' | ((resourcePath: string) => 'local' | 'global' | 'pure' | 'icss')`) — setup mode option +- `localIdentName` (`string`) — allows to configure the generated local ident name +- `localIdentContext` (`string`) — allows to redefine basic loader context for local ident name +- `localIdentHashSalt` (`string`) — allows to add custom hash to generate more unique classes +- `localIdentHashFunction` (`string`) — allows to specify hash function to generate classes +- `localIdentHashDigest` (`string`) — allows to specify hash digest to generate classes +- `localIdentHashDigestLength` (`number`) — allows to specify hash digest length to generate classes +- `hashStrategy` (`'resource-path-and-local-name' | 'minimal-subset'`) — allows to specify should localName be used when computing the hash +- `localIdentRegExp` (`string | RegExp`) — allows to specify custom RegExp for local ident name +- `getLocalIdent` (`(context: {resourcePath: string, resourceQuery: string}, localIdentName: string, localName: string, options: CssLoaderModulesOptions) => string`) — allows to specify a function to generate the classname +- `namedExport` (`boolean`) — enables/disables ES modules named export for locals +- `exportGlobals` (`boolean`) — allows to export names from global class or id, so you can use that as local name +- `exportLocalsConvention` (`'asIs' | 'as-is' | 'camelCase' | 'camel-case' | 'camelCaseOnly' | 'camel-case-only' | 'dashes' | 'dashesOnly' | 'dashes-only' | ((className: string) => string)`) — style of exported classnames +- `exportOnlyLocals` (`boolean`) — export only locals +- `getJSON` (`(cssModules: Record) => void`) — allows outputting of CSS modules mapping through a callback + +For more details, see [css-loader documentation](https://github.com/webpack/css-loader#options). + +**Default configuration:** + +```ts +{ + url: { + filter: (url: string) => !url.startsWith('data:'), + }, + sourceMap: !disableSourceMapGeneration, + modules: { + auto: true, + localIdentName: '[name]__[local]--[hash:base64:5]', + exportLocalsConvention: 'camelCase', + }, +} +``` + ##### Monaco editor support - `monaco` (`object`) — use [monaco-editor-webpack-plugin](https://github.com/microsoft/monaco-editor/tree/main/webpack-plugin#monaco-editor-webpack-loader-plugin) diff --git a/src/common/config.test.ts b/src/common/config.test.ts new file mode 100644 index 0000000..a1eb3ea --- /dev/null +++ b/src/common/config.test.ts @@ -0,0 +1,258 @@ +import {normalizeConfig} from './config'; +import type {ClientConfig, CssLoaderOptions} from './models'; + +// Type guard for url filter +function isUrlFilterObject( + url: CssLoaderOptions['url'], +): url is {filter: (url: string, resourcePath: string) => boolean} { + return typeof url === 'object' && url !== null && 'filter' in url; +} + +describe('cssLoader configuration', () => { + it('should apply default cssLoader config when not specified', async () => { + const clientConfig: ClientConfig = {}; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig).toEqual({ + url: { + filter: expect.any(Function), + }, + sourceMap: true, + modules: { + auto: true, + localIdentName: '[name]__[local]--[hash:base64:5]', + exportLocalsConvention: 'camelCase', + }, + }); + + // Check that url filter works correctly + const url = normalized.client.cssLoaderConfig.url; + if (isUrlFilterObject(url)) { + expect(url.filter('data:image/png;base64,abc', '/path/to/file.css')).toBe(false); + expect(url.filter('./image.png', '/path/to/file.css')).toBe(true); + } + }); + + it('should merge user cssLoader config with defaults', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + modules: { + localIdentName: '[local]--[hash:base64:8]', + exportLocalsConvention: 'camelCaseOnly', + }, + sourceMap: false, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig).toEqual({ + url: { + filter: expect.any(Function), + }, + sourceMap: false, + modules: { + auto: true, + localIdentName: '[local]--[hash:base64:8]', + exportLocalsConvention: 'camelCaseOnly', + }, + }); + }); + + it('should allow complete override of cssLoader config', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + url: false, + import: false, + modules: false, + sourceMap: false, + esModule: false, + exportType: 'array', + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig).toEqual({ + url: false, + import: false, + modules: false, + sourceMap: false, + esModule: false, + exportType: 'array', + }); + }); + + it('should allow partial override of modules config', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + modules: { + localIdentName: 'custom-[local]', + }, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.modules).toEqual({ + auto: true, + localIdentName: 'custom-[local]', + exportLocalsConvention: 'camelCase', + }); + }); + + it('should allow modules to be a boolean', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + modules: true, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.modules).toBe(true); + }); + + it('should allow modules to be a string', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + modules: 'local', + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.modules).toBe('local'); + }); + + it('should allow url to be a boolean', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + url: false, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.url).toBe(false); + }); + + it('should allow url to be an object with filter', async () => { + const customFilter = (url: string) => url.endsWith('.png'); + const clientConfig: ClientConfig = { + cssLoader: { + url: { + filter: customFilter, + }, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.url).toEqual({ + filter: customFilter, + }); + }); + + it('should respect disableSourceMapGeneration for sourceMap', async () => { + const clientConfig: ClientConfig = { + disableSourceMapGeneration: true, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.sourceMap).toBe(false); + }); + + it('should allow user to override sourceMap even with disableSourceMapGeneration', async () => { + const clientConfig: ClientConfig = { + disableSourceMapGeneration: true, + cssLoader: { + sourceMap: true, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.sourceMap).toBe(true); + }); + + it('should allow import to be a boolean', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + import: false, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.import).toBe(false); + }); + + it('should allow import to be an object with filter', async () => { + const customFilter = (_url: string, media: string) => media === 'screen'; + const clientConfig: ClientConfig = { + cssLoader: { + import: { + filter: customFilter, + }, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.import).toEqual({ + filter: customFilter, + }); + }); + + it('should allow setting exportType', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + exportType: 'css-style-sheet', + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.exportType).toBe('css-style-sheet'); + }); + + it('should allow setting esModule', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + esModule: false, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.esModule).toBe(false); + }); + + it('should allow complex modules configuration', async () => { + const clientConfig: ClientConfig = { + cssLoader: { + modules: { + auto: /\.module\.css$/, + mode: 'local', + localIdentName: '[path][name]__[local]--[hash:base64:5]', + localIdentContext: 'src', + localIdentHashSalt: 'custom-salt', + localIdentHashFunction: 'sha256', + localIdentHashDigest: 'hex', + localIdentHashDigestLength: 10, + hashStrategy: 'resource-path-and-local-name', + namedExport: true, + exportGlobals: true, + exportLocalsConvention: 'dashes', + exportOnlyLocals: false, + }, + }, + }; + const normalized = await normalizeConfig({client: clientConfig}); + + expect(normalized.client.cssLoaderConfig.modules).toEqual({ + auto: /\.module\.css$/, + mode: 'local', + localIdentName: '[path][name]__[local]--[hash:base64:5]', + localIdentContext: 'src', + localIdentHashSalt: 'custom-salt', + localIdentHashFunction: 'sha256', + localIdentHashDigest: 'hex', + localIdentHashDigestLength: 10, + hashStrategy: 'resource-path-and-local-name', + namedExport: true, + exportGlobals: true, + exportLocalsConvention: 'dashes', + exportOnlyLocals: false, + }); + }); +}); diff --git a/src/common/config.ts b/src/common/config.ts index c0a095e..ca6d677 100755 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -266,6 +266,28 @@ async function normalizeClientConfig(client: ClientConfig, mode?: 'dev' | 'build lazyCompilation: undefined, bundler: client.bundler || 'webpack', javaScriptLoader: client.javaScriptLoader || 'babel', + cssLoaderConfig: { + url: client.cssLoader?.url ?? { + filter: (url: string) => !url.startsWith('data:'), + }, + sourceMap: client.cssLoader?.sourceMap ?? !client.disableSourceMapGeneration, + modules: + typeof client.cssLoader?.modules === 'object' + ? { + auto: true, + localIdentName: '[name]__[local]--[hash:base64:5]', + exportLocalsConvention: 'camelCase', + ...client.cssLoader.modules, + } + : (client.cssLoader?.modules ?? { + auto: true, + localIdentName: '[name]__[local]--[hash:base64:5]', + exportLocalsConvention: 'camelCase', + }), + import: client.cssLoader?.import, + esModule: client.cssLoader?.esModule, + exportType: client.cssLoader?.exportType, + }, }; if (mode === 'dev') { diff --git a/src/common/models/index.ts b/src/common/models/index.ts index 0a04a91..761f531 100755 --- a/src/common/models/index.ts +++ b/src/common/models/index.ts @@ -354,6 +354,13 @@ export interface ClientConfig { options: LightningCssMinimizerRspackPluginOptions, ) => LightningCssMinimizerRspackPluginOptions; + /** + * CSS Loader configuration options + * Allows to override default css-loader settings + * @see https://github.com/webpack/css-loader#options + */ + cssLoader?: Partial; + ssr?: { noExternal?: string | RegExp | (string | RegExp)[] | true; moduleType?: 'commonjs' | 'esm'; @@ -463,6 +470,10 @@ export type NormalizedClientConfig = Omit< }; verbose?: boolean; transformCssWithLightningCss: boolean; + /** + * CSS Loader configuration with default values merged with user overrides + */ + cssLoaderConfig: CssLoaderOptions; webpack: ( config: Configuration, options: {configType: `${WebpackMode}`; isSsr: boolean}, @@ -523,3 +534,170 @@ export function isLibraryConfig(config: ProjectConfig): config is LibraryConfig export function defineConfig(config: ProjectFileConfig) { return config; } + +/** + * CSS Loader options interface + * @see https://github.com/webpack/css-loader#options + */ +export interface CssLoaderOptions { + /** + * Allows to enables/disables `url()`/`image-set()` functions handling. + * @see https://github.com/webpack/css-loader#url + */ + url?: boolean | {filter: (url: string, resourcePath: string) => boolean}; + + /** + * Allows to enables/disables `@import` at-rules handling. + * @see https://github.com/webpack/css-loader#import + */ + import?: boolean | {filter: (url: string, media: string, resourcePath: string) => boolean}; + + /** + * Allows to enable/disable CSS Modules or ICSS and setup configuration. + * @see https://github.com/webpack/css-loader#modules + */ + modules?: boolean | 'local' | 'global' | 'pure' | 'icss' | CssLoaderModulesOptions; + + /** + * Allows to enable/disable source maps. + * @see https://github.com/webpack/css-loader#sourcemap + */ + sourceMap?: boolean; + + /** + * Use the ES modules syntax. + * @see https://github.com/webpack/css-loader#esmodule + */ + esModule?: boolean; + + /** + * Allows exporting styles as array with modules, string or constructable stylesheet (i.e. `CSSStyleSheet`). + * @see https://github.com/webpack/css-loader#exporttype + */ + exportType?: 'array' | 'string' | 'css-style-sheet'; +} + +/** + * CSS Modules configuration options + * @see https://github.com/webpack/css-loader#modules + */ +export interface CssLoaderModulesOptions { + /** + * Allows auto enable CSS modules based on filename. + * @see https://github.com/webpack/css-loader#auto + */ + auto?: RegExp | ((resourcePath: string) => boolean) | boolean; + + /** + * Setup `mode` option. + * @see https://github.com/webpack/css-loader#mode + */ + mode?: + | 'local' + | 'global' + | 'pure' + | 'icss' + | ((resourcePath: string) => 'local' | 'global' | 'pure' | 'icss'); + + /** + * Allows to configure the generated local ident name. + * @see https://github.com/webpack/css-loader#localidentname + */ + localIdentName?: string; + + /** + * Allows to redefine basic loader context for local ident name. + * @see https://github.com/webpack/css-loader#localidentcontext + */ + localIdentContext?: string; + + /** + * Allows to add custom hash to generate more unique classes. + * @see https://github.com/webpack/css-loader#localidenthashsalt + */ + localIdentHashSalt?: string; + + /** + * Allows to specify hash function to generate classes. + * @see https://github.com/webpack/css-loader#localidenthashfunction + */ + localIdentHashFunction?: string; + + /** + * Allows to specify hash digest to generate classes. + * @see https://github.com/webpack/css-loader#localidenthashdigest + */ + localIdentHashDigest?: string; + + /** + * Allows to specify hash digest length to generate classes. + * @see https://github.com/webpack/css-loader#localidenthashdigestlength + */ + localIdentHashDigestLength?: number; + + /** + * Allows to specify should localName be used when computing the hash. + * @see https://github.com/webpack/css-loader#hashstrategy + */ + hashStrategy?: 'resource-path-and-local-name' | 'minimal-subset'; + + /** + * Allows to specify custom RegExp for local ident name. + * @see https://github.com/webpack/css-loader#localidentregexp + */ + localIdentRegExp?: string | RegExp; + + /** + * Allows to specify a function to generate the classname. + * @see https://github.com/webpack/css-loader#getlocalident + */ + getLocalIdent?: ( + context: { + resourcePath: string; + resourceQuery: string; + }, + localIdentName: string, + localName: string, + options: CssLoaderModulesOptions, + ) => string; + + /** + * Enables/disables ES modules named export for locals. + * @see https://github.com/webpack/css-loader#namedexport + */ + namedExport?: boolean; + + /** + * Allows to export names from global class or id, so you can use that as local name. + * @see https://github.com/webpack/css-loader#exportglobals + */ + exportGlobals?: boolean; + + /** + * Style of exported classnames. + * @see https://github.com/webpack/css-loader#localsconvention + */ + exportLocalsConvention?: + | 'asIs' + | 'as-is' + | 'camelCase' + | 'camel-case' + | 'camelCaseOnly' + | 'camel-case-only' + | 'dashes' + | 'dashesOnly' + | 'dashes-only' + | ((className: string) => string); + + /** + * Export only locals. + * @see https://github.com/webpack/css-loader#exportonlylocals + */ + exportOnlyLocals?: boolean; + + /** + * Allows outputting of CSS modules mapping through a callback. + * @see https://github.com/webpack/css-loader#getJSON + */ + getJSON?: (cssModules: Record) => void; +} diff --git a/src/common/webpack/config.ts b/src/common/webpack/config.ts index d0c4d38..76f85d4 100644 --- a/src/common/webpack/config.ts +++ b/src/common/webpack/config.ts @@ -836,20 +836,15 @@ function getCssLoaders( loaders.unshift({ loader: require.resolve('css-loader'), options: { - url: { - filter: (url: string) => { - // ignore data uri - return !url.startsWith('data:'); - }, - }, - sourceMap: !config.disableSourceMapGeneration, + ...config.cssLoaderConfig, importLoaders, - modules: { - auto: true, - localIdentName: '[name]__[local]--[hash:base64:5]', - exportLocalsConvention: 'camelCase', - exportOnlyLocals: isSsr, - }, + modules: + typeof config.cssLoaderConfig.modules === 'object' + ? { + ...config.cssLoaderConfig.modules, + exportOnlyLocals: isSsr, + } + : config.cssLoaderConfig.modules, }, });