diff --git a/package.json b/package.json index 2a82c1c..82c7410 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", + "defu": "^6.1.4", "rc-util": "^5.35.0", "stylis": "^4.0.13" }, diff --git a/src/transformers/px2rem.ts b/src/transformers/px2rem.ts index e6a0574..6a9b91b 100644 --- a/src/transformers/px2rem.ts +++ b/src/transformers/px2rem.ts @@ -3,9 +3,15 @@ */ // @ts-ignore import unitless from '@emotion/unitless'; +import { defuArrayFn } from 'defu'; import type { CSSObject } from '..'; import type { Transformer } from './interface'; +interface ConvertUnit { + source: string | RegExp; + target: string; +} + interface Options { /** * The root font size. @@ -22,49 +28,281 @@ interface Options { * @default false */ mediaQuery?: boolean; + /** + * The selector blackList. + * + */ + selectorBlackList?: { + /** + * The selector black list. + */ + match?: (string | RegExp)[]; + /** + * Whether to deep into the children. + * @default true + */ + deep?: boolean; + }; + /** + * The property list to convert. + * @default ['*'] + * @example + * ['font-size', 'margin'] + */ + propList?: string[]; + /** + * The minimum pixel value to transform. + * @default 1 + */ + minPixelValue?: number; + /** + * Convert unit on end. + * @default null + * @example + * ```js + * { + * source: /px$/i, + * target: 'px' + * } + * ``` + */ + convertUnit?: ConvertUnit | ConvertUnit[] | false | null; } -const pxRegex = /url\([^)]+\)|var\([^)]+\)|(\d*\.?\d+)px/g; +const pxRegex = /"[^"]+"|'[^']+'|url\([^)]+\)|--[\w-]+|(\d*\.?\d+)px/g; + +export const filterPropList = { + exact(list: string[]) { + return list.filter((m) => m.match(/^[^!*]+$/)); + }, + contain(list: string[]) { + return list.filter((m) => m.match(/^\*.+\*$/)).map((m) => m.slice(1, -1)); + }, + endWith(list: string[]) { + return list.filter((m) => m.match(/^\*[^*]+$/)).map((m) => m.slice(1)); + }, + startWith(list: string[]) { + return list + .filter((m) => m.match(/^[^!*]+\*$/)) + .map((m) => m.slice(0, Math.max(0, m.length - 1))); + }, + notExact(list: string[]) { + return list.filter((m) => m.match(/^![^*].*$/)).map((m) => m.slice(1)); + }, + notContain(list: string[]) { + return list.filter((m) => m.match(/^!\*.+\*$/)).map((m) => m.slice(2, -1)); + }, + notEndWith(list: string[]) { + return list.filter((m) => m.match(/^!\*[^*]+$/)).map((m) => m.slice(2)); + }, + notStartWith(list: string[]) { + return list.filter((m) => m.match(/^![^*]+\*$/)).map((m) => m.slice(1, -1)); + }, +}; + +function createPropListMatcher(propList: string[]) { + const hasWild = propList.includes('*'); + const matchAll = hasWild && propList.length === 1; + const lists = { + exact: filterPropList.exact(propList), + contain: filterPropList.contain(propList), + startWith: filterPropList.startWith(propList), + endWith: filterPropList.endWith(propList), + notExact: filterPropList.notExact(propList), + notContain: filterPropList.notContain(propList), + notStartWith: filterPropList.notStartWith(propList), + notEndWith: filterPropList.notEndWith(propList), + }; + return function (prop: string) { + if (matchAll) return true; + return ( + (hasWild || + lists.exact.includes(prop) || + lists.contain.some((m) => prop.includes(m)) || + lists.startWith.some((m) => prop.indexOf(m) === 0) || + lists.endWith.some( + (m) => prop.indexOf(m) === prop.length - m.length, + )) && + !( + lists.notExact.includes(prop) || + lists.notContain.some((m) => prop.includes(m)) || + lists.notStartWith.some((m) => prop.indexOf(m) === 0) || + lists.notEndWith.some((m) => prop.indexOf(m) === prop.length - m.length) + ) + ); + }; +} + +function createPxReplace( + rootValue: number, + precision: NonNullable, + minPixelValue: NonNullable, +) { + return (m: string, $1: string | null) => { + if (!$1) return m; + const pixels = Number.parseFloat($1); + if (pixels <= minPixelValue) return m; + const fixedVal = toFixed(pixels / rootValue, precision); + return fixedVal === 0 ? '0' : `${fixedVal}rem`; + }; +} function toFixed(number: number, precision: number) { - const multiplier = Math.pow(10, precision + 1), - wholeNumber = Math.floor(number * multiplier); + const multiplier = 10 ** (precision + 1); + const wholeNumber = Math.floor(number * multiplier); return (Math.round(wholeNumber / 10) * 10) / multiplier; } +function is(val: unknown, type: string) { + return Object.prototype.toString.call(val) === `[object ${type}]`; +} + +function isRegExp(data: unknown): data is RegExp { + return is(data, 'RegExp'); +} + +function isString(data: unknown): data is string { + return is(data, 'String'); +} + +function isObject(data: unknown): data is object { + return is(data, 'Object'); +} + +function isNumber(data: unknown): data is number { + return is(data, 'Number'); +} + +function blacklistedSelector(blacklist: (string | RegExp)[], selector: string) { + if (!isString(selector)) return; + return blacklist.some((t) => { + if (isString(t)) { + return selector.includes(t); + } + return selector.match(t); + }); +} + +const SKIP_SYMBOL = Symbol('skip_transform'); + +function defineSkipSymbol(obj: object) { + Reflect.defineProperty(obj, SKIP_SYMBOL, { + value: true, + enumerable: false, + writable: false, + configurable: false, + }); +} + +function getSkipSymbol(obj: object) { + return Reflect.get(obj, SKIP_SYMBOL); +} + +const uppercasePattern = /([A-Z])/g; +function hyphenateStyleName(name: string): string { + return name.replace(uppercasePattern, '-$1').toLowerCase(); +} + +function convertUnitFn(value: string, convert: ConvertUnit) { + const { source, target } = convert; + if (isRegExp(source)) { + return value.replace(new RegExp(source), target); + } + return value.replace(new RegExp(`${source}$`), target); +} + +const DEFAULT_OPTIONS: Required = { + rootValue: 16, + precision: 5, + mediaQuery: false, + minPixelValue: 1, + propList: ['*'], + selectorBlackList: { match: [], deep: true }, + convertUnit: null, +}; + +function resolveOptions(options: Options, defaults = DEFAULT_OPTIONS) { + return defuArrayFn(options, defaults); +} + const transform = (options: Options = {}): Transformer => { - const { rootValue = 16, precision = 5, mediaQuery = false } = options; + const opts = resolveOptions(options); - const pxReplace = (m: string, $1: any) => { - if (!$1) return m; - const pixels = parseFloat($1); - // covenant: pixels <= 1, not transform to rem @zombieJ - if (pixels <= 1) return m; - const fixedVal = toFixed(pixels / rootValue, precision); - return `${fixedVal}rem`; - }; + const { + rootValue, + precision, + minPixelValue, + propList, + mediaQuery, + convertUnit, + selectorBlackList, + } = opts; + + const pxReplace = createPxReplace(rootValue, precision, minPixelValue); + const satisfyPropList = createPropListMatcher(propList); const visit = (cssObj: CSSObject): CSSObject => { + const skip = getSkipSymbol(cssObj); + const clone: CSSObject = { ...cssObj }; - Object.entries(cssObj).forEach(([key, value]) => { - if (typeof value === 'string' && value.includes('px')) { - const newValue = value.replace(pxRegex, pxReplace); - clone[key] = newValue; + if (skip) { + if (selectorBlackList.deep) { + Object.values(clone).forEach((value) => { + if (value && isObject(value)) { + defineSkipSymbol(value); + } + }); } + return clone; + } - // no unit - if (!unitless[key] && typeof value === 'number' && value !== 0) { - clone[key] = `${value}px`.replace(pxRegex, pxReplace); - } + Object.entries(cssObj).forEach(([key, value]) => { + if (!isObject(value)) { + if (!satisfyPropList(hyphenateStyleName(key))) { + // Current style property is not in the propList + // Skip + return; + } + + if (isString(value) && value.includes('px')) { + const newValue = value.replace(pxRegex, pxReplace); + clone[key] = newValue; + } + + // no unit + if (!unitless[key] && isNumber(value) && value !== 0) { + clone[key] = `${value}px`.replace(pxRegex, pxReplace); + } + + if (convertUnit && isString(clone[key])) { + const newValue = clone[key] as string; + if (Array.isArray(convertUnit)) { + clone[key] = convertUnit.reduce((c, conv) => { + return convertUnitFn(c, conv); + }, newValue); + } else { + clone[key] = convertUnitFn(newValue, convertUnit); + } + } + } else { + if (blacklistedSelector(selectorBlackList.match || [], key)) { + defineSkipSymbol(value); + return; + } - // Media queries - const mergedKey = key.trim(); - if (mergedKey.startsWith('@') && mergedKey.includes('px') && mediaQuery) { - const newKey = key.replace(pxRegex, pxReplace); + // Media queries + const mergedKey = key.trim(); + if ( + mergedKey.startsWith('@') && + mergedKey.includes('px') && + mediaQuery + ) { + const newKey = key.replace(pxRegex, pxReplace); - clone[newKey] = clone[key]; - delete clone[key]; + clone[newKey] = clone[key]; + delete clone[key]; + } } }); diff --git a/tests/transform.spec.tsx b/tests/transform.spec.tsx index 0538552..ac5fb57 100644 --- a/tests/transform.spec.tsx +++ b/tests/transform.spec.tsx @@ -9,7 +9,7 @@ import { StyleProvider, useStyleRegister, } from '../src'; -// import { getStyleText } from './util'; +import { filterPropList } from '../src/transformers/px2rem'; describe('transform', () => { beforeEach(() => { @@ -365,5 +365,324 @@ describe('transform', () => { testPx2rem(options, css, expected); }); }); + + describe('minPixelValue', () => { + it('should not replace values less than minPixelValue', () => { + const options = { + minPixelValue: 2, + }; + + const css: CSSInterpolation = { + '.rule': { + border: '1px solid #000', + fontSize: '16px', + margin: '1px 10px', + }, + }; + const expected = + '.rule{border:1px solid #000;font-size:1rem;margin:1px 0.625rem;}'; + + testPx2rem(options, css, expected); + }); + }); + + describe('selectorBlack', () => { + it('should not replace values in selectors that match the selectorBlackList - string', () => { + const options = { + selectorBlackList: { + match: ['.rule'], + }, + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16px', + '.inner': { + fontSize: '32px', + }, + }, + }; + + const expected = '.rule{font-size:16px;}.rule .inner{font-size:32px;}'; + + testPx2rem(options, css, expected); + }); + + it('should not replace values in selectors that match the selectorBlackList - regex', () => { + const options = { + selectorBlackList: { + match: [/^\.rule$/], + }, + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16px', + '.inner': { + fontSize: '32px', + }, + }, + }; + + const expected = '.rule{font-size:16px;}.rule .inner{font-size:32px;}'; + + testPx2rem(options, css, expected); + }); + + it('should not replace deep selectors', () => { + const options = { + selectorBlackList: { + match: ['.rule'], + deep: true, + }, + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16px', + '.inner': { + fontSize: '32px', + }, + }, + }; + + const expected = '.rule{font-size:16px;}.rule .inner{font-size:32px;}'; + + testPx2rem(options, css, expected); + }); + + it('should replace deep selectors', () => { + const options = { + selectorBlackList: { + match: ['.rule'], + deep: false, + }, + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16px', + '.inner': { + fontSize: '32px', + }, + }, + }; + + const expected = '.rule{font-size:16px;}.rule .inner{font-size:2rem;}'; + + testPx2rem(options, css, expected); + }); + }); + + describe('propList', () => { + it('should filter prop with margin', () => { + const options = { + propList: ['font-size', 'margin', '!padding', '!*font*'], + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16px', + lineHeight: '16px', + margin: '16px', + padding: '32px', + '.inner': { + fontSize: '16px', + padding: '16px', + }, + }, + }; + + const expected = + '.rule{font-size:16px;line-height:1rem;margin:1rem;padding:32px;}.rule .inner{font-size:16px;padding:16px;}'; + + testPx2rem(options, css, expected); + }); + }); + + describe('convertUnit', () => { + it('should convert PX to px with regexp', () => { + const options = { + convertUnit: { + source: /px$/i, + target: 'px', + }, + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16PX', + lineHeight: '16Px', + margin: '16pX', + }, + }; + + const expected = '.rule{font-size:16px;line-height:16px;margin:16px;}'; + + testPx2rem(options, css, expected); + }); + + it('should convert PX to px with function', () => { + const options = { + convertUnit: { + source: 'PX', + target: 'px', + }, + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16PX', + lineHeight: '16Px', + margin: '16px', + }, + }; + + const expected = '.rule{font-size:16px;line-height:16Px;margin:1rem;}'; + + testPx2rem(options, css, expected); + }); + + it('should convert unit by order', () => { + const options = { + convertUnit: [ + { + source: /px$/i, + target: 'px', + }, + { + source: /rpx$/i, + target: 'px', + }, + ], + }; + + const css: CSSInterpolation = { + '.rule': { + fontSize: '16PX', + lineHeight: '16Px', + margin: '16rpx', + }, + }; + + const expected = '.rule{font-size:16px;line-height:16px;margin:16px;}'; + + testPx2rem(options, css, expected); + }); + }); + }); + + describe('filter-prop-list', () => { + it('should find "exact" matches from propList', () => { + const propList = [ + 'font-size', + 'margin', + '!padding', + '*border*', + '*', + '*y', + '!*font*', + ]; + const expected = 'font-size,margin'; + expect(filterPropList.exact(propList).join()).toBe(expected); + }); + + it('should find "contain" matches from propList and reduce to string', () => { + const propList = [ + 'font-size', + '*margin*', + '!padding', + '*border*', + '*', + '*y', + '!*font*', + ]; + const expected = 'margin,border'; + expect(filterPropList.contain(propList).join()).toBe(expected); + }); + + it('should find "start" matches from propList and reduce to string', () => { + const propList = [ + 'font-size', + '*margin*', + '!padding', + 'border*', + '*', + '*y', + '!*font*', + ]; + const expected = 'border'; + expect(filterPropList.startWith(propList).join()).toBe(expected); + }); + + it('should find "end" matches from propList and reduce to string', () => { + const propList = [ + 'font-size', + '*margin*', + '!padding', + 'border*', + '*', + '*y', + '!*font*', + ]; + const expected = 'y'; + expect(filterPropList.endWith(propList).join()).toBe(expected); + }); + + it('should find "not" matches from propList and reduce to string', () => { + const propList = [ + 'font-size', + '*margin*', + '!padding', + 'border*', + '*', + '*y', + '!*font*', + ]; + const expected = 'padding'; + expect(filterPropList.notExact(propList).join()).toBe(expected); + }); + + it('should find "not contain" matches from propList and reduce to string', () => { + const propList = [ + 'font-size', + '*margin*', + '!padding', + '!border*', + '*', + '*y', + '!*font*', + ]; + const expected = 'font'; + expect(filterPropList.notContain(propList).join()).toBe(expected); + }); + + it('should find "not start" matches from propList and reduce to string', () => { + const propList = [ + 'font-size', + '*margin*', + '!padding', + '!border*', + '*', + '*y', + '!*font*', + ]; + const expected = 'border'; + expect(filterPropList.notStartWith(propList).join()).toBe(expected); + }); + + it('should find "not end" matches from propList and reduce to string', () => { + const propList = [ + 'font-size', + '*margin*', + '!padding', + '!border*', + '*', + '!*y', + '!*font*', + ]; + const expected = 'y'; + expect(filterPropList.notEndWith(propList).join()).toBe(expected); + }); }); });