diff --git a/README.md b/README.md index 94ca95a9..95392f3f 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,7 @@ All the options are optional, and a default value will be used if any of them is | stringMap | `PluginStringMap[]` | Check below | An array of strings maps that will be used to make the replacements of the declarations' URLs and to match the names of the rules if `processRuleNames` is `true` | | greedy | `boolean` | `false` | When greedy is `true`, the matches of `stringMap` will not take into account word boundaries | | aliases | `Record` | `{}` | A strings map to treat some declarations as others | +| processDeclarationPlugins | `DeclarationPlugin[]` | `[]` | Plugins applied when processing CSS declarations | --- @@ -1447,6 +1448,62 @@ const options = { --- +#### processDeclarationPlugins + +
Expand +

+ +The intention of the processDeclarationPlugins option is to process the declarations to extend or override RTLCSS functionality. For example, we can avoid automatically flipping of `background-potion`. + +##### input + +```css +.test { + background-position: 0 100%; +} +``` + +##### Convert `0` to `100%` (default) + +##### output + +```css +.test { + background-position: 100% 100%; +} +``` + +##### Set a plugin to avoid flipping + +```javascript +const options = { + processDeclarationPlugins: [ + { + name: 'avoid-flipping-background', + priority: 99, // above the core RTLCSS plugin which has a priority value of 100 + processors: [{ + expr: /(background|object)(-position(-x)?|-image)?$/i, + action: (prop, value) => ({prop, value})} + ] + } + ] +}; +``` + +##### output + +```css +.test { + background-position: 0 100%; +} +``` + +

+ +
+ +--- + Control Directives --- diff --git a/src/@types/index.ts b/src/@types/index.ts index 4bcc49e0..20b4076d 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -39,6 +39,26 @@ export interface PluginStringMap { replace: strings; } +export type RTLCSSPlugin = { + name: string; + priority: number; + directives: { + control: object, + value: Array + }; +} + +export interface DeclarationPluginProcessor { + expr: RegExp; + action: (prop: string, value: string, context: object) => object; +} + +export type DeclarationPlugin = { + name: string; + priority: number; + processors: DeclarationPluginProcessor[]; +} + export type PrefixSelectorTransformer = (prefix: string, selector: string) => string | void; export interface PluginOptions { @@ -58,10 +78,12 @@ export interface PluginOptions { stringMap?: PluginStringMap[]; greedy?: boolean; aliases?: Record; + processDeclarationPlugins?: DeclarationPlugin[]; } -export interface PluginOptionsNormalized extends Omit, 'stringMap' | 'prefixSelectorTransformer'> { +export interface PluginOptionsNormalized extends Omit, 'stringMap' | 'processDeclarationPlugins' | 'prefixSelectorTransformer'> { stringMap: StringMap[]; + plugins: RTLCSSPlugin[]; prefixSelectorTransformer: PrefixSelectorTransformer | null; } diff --git a/src/constants/index.ts b/src/constants/index.ts index db354487..4222254b 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -3,6 +3,7 @@ export const DECLARATION_TYPE = 'decl'; export const RULE_TYPE = 'rule'; export const AT_RULE_TYPE = 'atrule'; export const STRING_TYPE = 'string'; +export const NUMBER_TYPE = 'number'; export const BOOLEAN_TYPE = 'boolean'; export const FUNCTION_TYPE = 'function'; export const KEYFRAMES_NAME = 'keyframes'; diff --git a/src/data/store.ts b/src/data/store.ts index 8d23ee7f..b20d3157 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -2,6 +2,8 @@ import { Rule, AtRule } from 'postcss'; import { PluginOptions, PluginOptionsNormalized, + DeclarationPlugin, + DeclarationPluginProcessor, AtRulesObject, AtRulesStringMap, RulesObject, @@ -16,6 +18,8 @@ import { } from '@types'; import { BOOLEAN_TYPE, + STRING_TYPE, + NUMBER_TYPE, FUNCTION_TYPE, REG_EXP_CHARACTERS_REG_EXP, LAST_WORD_CHARACTER_REG_EXP @@ -99,6 +103,18 @@ const isNotAcceptedStringMap = (stringMap: PluginStringMap[]): boolean => { ); }; +const isAcceptedProcessDeclarationPlugins = (plugins: DeclarationPlugin[]): boolean => + Array.isArray(plugins) + && plugins.every((plugin: DeclarationPlugin) => + typeof plugin.name == STRING_TYPE + && typeof plugin.priority == NUMBER_TYPE + && Array.isArray(plugin.processors) + && plugin.processors.every((processor: DeclarationPluginProcessor) => + processor.expr instanceof RegExp + && typeof processor.action === FUNCTION_TYPE + ) + ); + const isObjectWithStringKeys = (obj: Record): boolean => !Object.entries(obj).some( (entry: [string, unknown]): boolean => @@ -143,7 +159,8 @@ const defaultOptions = (): PluginOptionsNormalized => ({ useCalc: false, stringMap: getRTLCSSStringMap(defaultStringMap), greedy: false, - aliases: {} + aliases: {}, + plugins: [] }); const store: Store = { @@ -214,6 +231,11 @@ const normalizeOptions = (options: PluginOptions): PluginOptionsNormalized => { } }); } + if (isAcceptedProcessDeclarationPlugins(options.processDeclarationPlugins)) { + returnOptions.plugins = options.processDeclarationPlugins.map(plugin => ({ + ...plugin, directives: {control: {}, value: []}, + })); + } if (options.aliases && isObjectWithStringKeys(options.aliases)) { returnOptions.aliases = options.aliases; } @@ -250,4 +272,4 @@ const initKeyframesData = (): void => { store.keyframesRegExp = getKeyFramesRegExp(store.keyframesStringMap); }; -export { store, initStore, initKeyframesData }; \ No newline at end of file +export { store, initStore, initKeyframesData }; diff --git a/src/parsers/declarations.ts b/src/parsers/declarations.ts index 5e5b9b7c..eba89b05 100644 --- a/src/parsers/declarations.ts +++ b/src/parsers/declarations.ts @@ -59,7 +59,8 @@ export const parseDeclarations = ( useCalc, stringMap, greedy, - aliases + aliases, + plugins } = store.options; const deleteDeclarations: Declaration[] = []; @@ -160,7 +161,7 @@ export const parseDeclarations = ( stringMap, greedy, aliases - }); + }, plugins); /* the source could be undefined in certain cases but not during the tests */ /* istanbul ignore next */ diff --git a/tests/__snapshots__/process-declaration-plugins/combined/flip.snapshot b/tests/__snapshots__/process-declaration-plugins/combined/flip.snapshot new file mode 100644 index 00000000..3a1cf678 --- /dev/null +++ b/tests/__snapshots__/process-declaration-plugins/combined/flip.snapshot @@ -0,0 +1,183 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[[Mode: combined]] flip background by default 1`] = ` +"[dir="ltr"] .test1 { + background: url("/icons/icon-left.png") 0 100%; +} + +[dir="rtl"] .test1 { + background: url("/icons/icon-left.png") 100% 100%; +} + +.test2 { + background-image: url("/icons/icon-left.png"); +} + +[dir="ltr"] .test2 { + background-position: 0 100%; +} + +[dir="rtl"] .test2 { + background-position: 100% 100%; +} + +.test3 { + background-image: url("/icons/icon-left.png"); + background-position-y: 100%; +} + +[dir="ltr"] .test3 { + background-position-x: 0; +} + +[dir="rtl"] .test3 { + background-position-x: 100%; +} + +[dir="ltr"] .test4 { + object-position: 0 100%; +} + +[dir="rtl"] .test4 { + object-position: 100% 100%; +} + +/* inside a nested rule */ +.test1 { + .test3 { + background-image: url("/icons/icon-left.png"); + } + + > .test4 { + background-image: url("/icons/icon-left.png"); + background-position-y: 100%; + } +} + +[dir="ltr"] .test1 { + &.test2 { + background: url("/icons/icon-left.png") 0 100%; + } + + .test3 { + background-position: 0 100%; + } + + > .test4 { + background-position-x: 0; + } + + + .test5 { + object-position: 0 100%; + } +} + +[dir="rtl"] .test1 { + &.test2 { + background: url("/icons/icon-left.png") 100% 100%; + } + + .test3 { + background-position: 100% 100%; + } + + > .test4 { + background-position-x: 100%; + } + + + .test5 { + object-position: 100% 100%; + } +} + +/* inside a keyframe animation */ +@keyframes flip1 { + from { + background: url("/icons/icon-left.png") 0 100%; + } + + to { + background: url("/icons/icon-left.png") 100% 100%; + } +} + +@keyframes flip2 { + from { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position: 100% 100%; + } +} + +@keyframes flip3 { + from { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position-x: 100%; + background-position-y: 100%; + } +} + +@keyframes flip4 { + from { + object-position: 0 100%; + } + + to { + object-position: 100% 100%; + } +} + +/* inside a media-query */ +@media screen and (max-width: 800px) { + [dir="ltr"] .test1 { + background: url("/icons/icon-left.png") 0 100%; + } + + [dir="rtl"] .test1 { + background: url("/icons/icon-left.png") 100% 100%; + } + + .test2 { + background-image: url("/icons/icon-left.png"); + } + + [dir="ltr"] .test2 { + background-position: 0 100%; + } + + [dir="rtl"] .test2 { + background-position: 100% 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position-y: 100%; + } + + [dir="ltr"] .test3 { + background-position-x: 0; + } + + [dir="rtl"] .test3 { + background-position-x: 100%; + } + + [dir="ltr"] .test4 { + object-position: 0 100%; + } + + [dir="rtl"] .test4 { + object-position: 100% 100%; + } +}" +`; diff --git a/tests/__snapshots__/process-declaration-plugins/combined/noflip.snapshot b/tests/__snapshots__/process-declaration-plugins/combined/noflip.snapshot new file mode 100644 index 00000000..718aeca4 --- /dev/null +++ b/tests/__snapshots__/process-declaration-plugins/combined/noflip.snapshot @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[[Mode: combined]] use {processDeclarationPlugins} to avoid flipping background 1`] = ` +".test1 { + background: url("/icons/icon-left.png") 0 100%; +} + +.test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; +} + +.test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; +} + +.test4 { + object-position: 0 100%; +} + +/* inside a nested rule */ +.test1 { + &.test2 { + background: url("/icons/icon-left.png") 0 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + > .test4 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + + .test5 { + object-position: 0 100%; + } +} + +/* inside a keyframe animation */ +@keyframes flip1 { + from { + background: url("/icons/icon-left.png") 0 100%; + } + + to { + background: url("/icons/icon-left.png") 100% 100%; + } +} + +@keyframes flip2 { + from { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position: 100% 100%; + } +} + +@keyframes flip3 { + from { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position-x: 100%; + background-position-y: 100%; + } +} + +@keyframes flip4 { + from { + object-position: 0 100%; + } + + to { + object-position: 100% 100%; + } +} + +/* inside a media-query */ +@media screen and (max-width: 800px) { + .test1 { + background: url("/icons/icon-left.png") 0 100%; + } + + .test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + .test4 { + object-position: 0 100%; + } +}" +`; diff --git a/tests/__snapshots__/process-declaration-plugins/diff/flip.snapshot b/tests/__snapshots__/process-declaration-plugins/diff/flip.snapshot new file mode 100644 index 00000000..b6be268d --- /dev/null +++ b/tests/__snapshots__/process-declaration-plugins/diff/flip.snapshot @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[[Mode: diff]] flip background by default 1`] = ` +".test1 { + background: url("/icons/icon-left.png") 100% 100%; +} + +.test2 { + background-position: 100% 100%; +} + +.test3 { + background-position-x: 100%; +} + +.test4 { + object-position: 100% 100%; +} + +.test1 { + &.test2 { + background: url("/icons/icon-left.png") 100% 100%; + } + + .test3 { + background-position: 100% 100%; + } + + > .test4 { + background-position-x: 100%; + } + + + .test5 { + object-position: 100% 100%; + } +} + +@media screen and (max-width: 800px) { + .test1 { + background: url("/icons/icon-left.png") 100% 100%; + } + + .test2 { + background-position: 100% 100%; + } + + .test3 { + background-position-x: 100%; + } + + .test4 { + object-position: 100% 100%; + } +}" +`; diff --git a/tests/__snapshots__/process-declaration-plugins/diff/noflip.snapshot b/tests/__snapshots__/process-declaration-plugins/diff/noflip.snapshot new file mode 100644 index 00000000..955e5075 --- /dev/null +++ b/tests/__snapshots__/process-declaration-plugins/diff/noflip.snapshot @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[[Mode: diff]] use {processDeclarationPlugins} to avoid flipping background 1`] = `""`; diff --git a/tests/__snapshots__/process-declaration-plugins/override/flip.snapshot b/tests/__snapshots__/process-declaration-plugins/override/flip.snapshot new file mode 100644 index 00000000..96b2d465 --- /dev/null +++ b/tests/__snapshots__/process-declaration-plugins/override/flip.snapshot @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[[Mode: override]] flip background by default 1`] = ` +".test1 { + background: url("/icons/icon-left.png") 0 100%; +} + +[dir="rtl"] .test1 { + background: url("/icons/icon-left.png") 100% 100%; +} + +.test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; +} + +[dir="rtl"] .test2 { + background-position: 100% 100%; +} + +.test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; +} + +[dir="rtl"] .test3 { + background-position-x: 100%; +} + +.test4 { + object-position: 0 100%; +} + +[dir="rtl"] .test4 { + object-position: 100% 100%; +} + +/* inside a nested rule */ +.test1 { + &.test2 { + background: url("/icons/icon-left.png") 0 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + > .test4 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + + .test5 { + object-position: 0 100%; + } +} + +[dir="rtl"] .test1 { + &.test2 { + background: url("/icons/icon-left.png") 100% 100%; + } + + .test3 { + background-position: 100% 100%; + } + + > .test4 { + background-position-x: 100%; + } + + + .test5 { + object-position: 100% 100%; + } +} + +/* inside a keyframe animation */ +@keyframes flip1 { + from { + background: url("/icons/icon-left.png") 0 100%; + } + + to { + background: url("/icons/icon-left.png") 100% 100%; + } +} + +@keyframes flip2 { + from { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position: 100% 100%; + } +} + +@keyframes flip3 { + from { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position-x: 100%; + background-position-y: 100%; + } +} + +@keyframes flip4 { + from { + object-position: 0 100%; + } + + to { + object-position: 100% 100%; + } +} + +/* inside a media-query */ +@media screen and (max-width: 800px) { + .test1 { + background: url("/icons/icon-left.png") 0 100%; + } + + [dir="rtl"] .test1 { + background: url("/icons/icon-left.png") 100% 100%; + } + + .test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + [dir="rtl"] .test2 { + background-position: 100% 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + [dir="rtl"] .test3 { + background-position-x: 100%; + } + + .test4 { + object-position: 0 100%; + } + + [dir="rtl"] .test4 { + object-position: 100% 100%; + } +}" +`; diff --git a/tests/__snapshots__/process-declaration-plugins/override/noflip.snapshot b/tests/__snapshots__/process-declaration-plugins/override/noflip.snapshot new file mode 100644 index 00000000..beaad981 --- /dev/null +++ b/tests/__snapshots__/process-declaration-plugins/override/noflip.snapshot @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[[Mode: override]] use {processDeclarationPlugins} to avoid flipping background 1`] = ` +".test1 { + background: url("/icons/icon-left.png") 0 100%; +} + +.test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; +} + +.test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; +} + +.test4 { + object-position: 0 100%; +} + +/* inside a nested rule */ +.test1 { + &.test2 { + background: url("/icons/icon-left.png") 0 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + > .test4 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + + .test5 { + object-position: 0 100%; + } +} + +/* inside a keyframe animation */ +@keyframes flip1 { + from { + background: url("/icons/icon-left.png") 0 100%; + } + + to { + background: url("/icons/icon-left.png") 100% 100%; + } +} + +@keyframes flip2 { + from { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position: 100% 100%; + } +} + +@keyframes flip3 { + from { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + to { + background-image: url("/icons/icon-left.png"); + background-position-x: 100%; + background-position-y: 100%; + } +} + +@keyframes flip4 { + from { + object-position: 0 100%; + } + + to { + object-position: 100% 100%; + } +} + +/* inside a media-query */ +@media screen and (max-width: 800px) { + .test1 { + background: url("/icons/icon-left.png") 0 100%; + } + + .test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + .test4 { + object-position: 0 100%; + } +}" +`; diff --git a/tests/css/input-process-declaration-plugins.scss b/tests/css/input-process-declaration-plugins.scss new file mode 100644 index 00000000..a58b4472 --- /dev/null +++ b/tests/css/input-process-declaration-plugins.scss @@ -0,0 +1,104 @@ +.test1 { + background: url("/icons/icon-left.png") 0 100%; +} + +.test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; +} + +.test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; +} + +.test4 { + object-position: 0 100%; +} + +/* inside a nested rule */ +.test1 { + &.test2 { + background: url("/icons/icon-left.png") 0 100%; + } + .test3 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + > .test4 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + + .test5 { + object-position: 0 100%; + } +} + +/* inside a keyframe animation */ +@keyframes flip1 { + from { + background: url("/icons/icon-left.png") 0 100%; + } + to { + background: url("/icons/icon-left.png") 100% 100%; + } +} + +@keyframes flip2 { + from { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + to { + background-image: url("/icons/icon-left.png"); + background-position: 100% 100%; + } +} + +@keyframes flip3 { + from { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + to { + background-image: url("/icons/icon-left.png"); + background-position-x: 100%; + background-position-y: 100%; + } +} + +@keyframes flip4 { + from { + object-position: 0 100%; + } + to { + object-position: 100% 100%; + } +} + +/* inside a media-query */ +@media screen and (max-width: 800px) { + .test1 { + background: url("/icons/icon-left.png") 0 100%; + } + + .test2 { + background-image: url("/icons/icon-left.png"); + background-position: 0 100%; + } + + .test3 { + background-image: url("/icons/icon-left.png"); + background-position-x: 0; + background-position-y: 100%; + } + + .test4 { + object-position: 0 100%; + } +} \ No newline at end of file diff --git a/tests/process-declaration-plugins.test.ts b/tests/process-declaration-plugins.test.ts new file mode 100644 index 00000000..8016ec9e --- /dev/null +++ b/tests/process-declaration-plugins.test.ts @@ -0,0 +1,52 @@ +import postcss from 'postcss'; +import postcssRTLCSS from '../src'; +import {PluginOptions} from '../src/@types'; +import { + readCSSFile, + runTests, + createSnapshotFileName +} from './utils'; +import 'jest-specific-snapshot'; + +const BASE_NAME = 'process-declaration-plugins'; + +runTests({}, (pluginOptions: PluginOptions): void => { + + describe(`[[Mode: ${pluginOptions.mode}]]`, (): void => { + + let input = ''; + + beforeEach(async (): Promise => { + input = input || await readCSSFile(`input-${BASE_NAME}.scss`); + }); + + it('flip background by default', (): void => { + const output = postcss([postcssRTLCSS(pluginOptions)]).process(input); + expect(output.css).toMatchSpecificSnapshot( + createSnapshotFileName(BASE_NAME, 'flip', pluginOptions.mode) + ); + expect(output.warnings()).toHaveLength(0); + }); + + it('use {processDeclarationPlugins} to avoid flipping background', (): void => { + const options: PluginOptions = { + ...pluginOptions, + processDeclarationPlugins: [{ + name: 'avoid-flipping-background', + priority: 99, // above the core RTLCSS plugin which has a priority value of 100 + processors: [{ + expr: /(background|object)(-position(-x)?|-image)?$/i, + action: (prop: string, value: string) => ({prop, value}) + }] + }] + }; + const output = postcss([postcssRTLCSS(options)]).process(input); + expect(output.css).toMatchSpecificSnapshot( + createSnapshotFileName(BASE_NAME, 'noflip', pluginOptions.mode) + ); + expect(output.warnings()).toHaveLength(0); + }); + + }); + +}); \ No newline at end of file