UNPKG

@mpxjs/webpack-plugin

Version:

mpx compile core

613 lines (600 loc) 25.9 kB
const { hump2dash } = require('../../../utils/hump-dash') module.exports = function getSpec ({ warn, error }) { // React Native 双端都不支持的 CSS property const unsupportedPropExp = /^(white-space|text-overflow|animation|transition|font-variant-caps|font-variant-numeric|font-variant-east-asian|font-variant-alternates|font-variant-ligatures|background-position|caret-color)$/ const unsupportedPropMode = { // React Native ios 不支持的 CSS property ios: /^(vertical-align)$/, // React Native android 不支持的 CSS property android: /^(text-decoration-style|text-decoration-color|shadow-offset|shadow-opacity|shadow-radius)$/, // TODO: rnoh 文档暂未找到 css 属性支持说明,暂时同步 android,同时需要注意此处校验是否有缺失,类似 will-change 之类属性 harmony: /^(text-decoration-style|text-decoration-color|shadow-offset|shadow-opacity|shadow-radius)$/ } // var(xx) const cssVariableExp = /var\(/ // calc(xx) const calcExp = /calc\(/ const envExp = /env\(/ // 不支持的属性提示 const unsupportedPropError = ({ prop, value, selector }, { mode }, isError = true) => { const tips = isError ? error : warn tips(`Property [${prop}] on ${selector} is not supported in ${mode} environment!`) } // prop 校验 const verifyProps = ({ prop, value, selector }, { mode }, isError = true) => { prop = prop.trim() if (unsupportedPropExp.test(prop) || unsupportedPropMode[mode].test(prop)) { unsupportedPropError({ prop, value, selector }, { mode }, isError) return false } return true } // 值类型 const ValueType = { number: 'number', color: 'color', enum: 'enum' } // React 属性支持的枚举值 const SUPPORTED_PROP_VAL_ARR = { 'box-sizing': ['border-box'], 'backface-visibility': ['visible', 'hidden'], overflow: ['visible', 'hidden', 'scroll'], 'border-style': ['solid', 'dotted', 'dashed'], 'object-fit': ['cover', 'contain', 'fill', 'scale-down'], direction: ['inherit', 'ltr', 'rtl'], display: ['flex', 'none'], 'flex-direction': ['row', 'row-reverse', 'column', 'column-reverse'], 'flex-wrap': ['wrap', 'nowrap', 'wrap-reverse'], 'pointer-events': ['auto', 'box-none', 'box-only', 'none'], 'vertical-align': ['auto', 'top', 'bottom', 'center'], position: ['relative', 'absolute', 'fixed'], 'font-variant': ['small-caps', 'oldstyle-nums', 'lining-nums', 'tabular-nums', 'proportional-nums'], 'text-align': ['left', 'right', 'center', 'justify'], 'font-style': ['normal', 'italic'], 'font-weight': ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], 'text-decoration-line': ['none', 'underline', 'line-through', 'underline line-through'], 'text-decoration-style': ['solid', 'double', 'dotted', 'dashed'], 'text-transform': ['none', 'uppercase', 'lowercase', 'capitalize'], 'user-select': ['auto', 'text', 'none', 'contain', 'all'], 'align-content': ['flex-start', 'flex-end', 'center', 'stretch', 'space-between', 'space-around', 'space-evenly'], 'align-items': ['flex-start', 'flex-end', 'center', 'stretch', 'baseline'], 'align-self': ['auto', 'flex-start', 'flex-end', 'center', 'stretch', 'baseline'], 'justify-content': ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly'], 'background-size': ['contain', 'cover', 'auto', ValueType.number], 'background-position': ['left', 'right', 'top', 'bottom', 'center', ValueType.number], 'background-repeat': ['no-repeat'], width: ['auto', ValueType.number], height: ['auto', ValueType.number], 'flex-basis': ['auto', ValueType.number], margin: ['auto', ValueType.number], 'margin-top': ['auto', ValueType.number], 'margin-left': ['auto', ValueType.number], 'margin-bottom': ['auto', ValueType.number], 'margin-right': ['auto', ValueType.number], 'margin-horizontal': ['auto', ValueType.number], 'margin-vertical': ['auto', ValueType.number] } // 获取值类型 const getValueType = (prop) => { const propValueTypeRules = [ // 重要!!优先判断是不是枚举类型 [ValueType.enum, new RegExp('^(' + Object.keys(SUPPORTED_PROP_VAL_ARR).join('|') + ')$')], [ValueType.number, /^((opacity|flex-grow|flex-shrink|gap|left|right|top|bottom)|(.+-(width|height|left|right|top|bottom|radius|spacing|size|gap|index|offset|opacity)))$/], [ValueType.color, /^(color|(.+-color))$/] ] for (const rule of propValueTypeRules) { if (rule[1].test(prop)) return rule[0] } } // 多value解析 const parseValues = (str, char = ' ') => { let stack = 0 let temp = '' const result = [] for (let i = 0; i < str.length; i++) { if (str[i] === '(') { stack++ } else if (str[i] === ')') { stack-- } // 非括号内 或者 非分隔字符且非空 if (stack !== 0 || (str[i] !== char && str[i] !== ' ')) { temp += str[i] } if ((stack === 0 && str[i] === char) || i === str.length - 1) { result.push(temp) temp = '' } } return result } // const getDefaultValueFromVar = (str) => { // const totalVarExp = /^var\((.+)\)$/ // if (!totalVarExp.test(str)) return str // const newVal = parseValues((str.match(totalVarExp)?.[1] || ''), ',') // if (newVal.length <= 1) return '' // if (!totalVarExp.test(newVal[1])) return newVal[1] // return getDefaultValueFromVar(newVal[1]) // } // 属性值校验 const verifyValues = ({ prop, value, selector }, isError = true) => { prop = prop.trim() value = value.trim() const tips = isError ? error : warn if (cssVariableExp.test(value) || calcExp.test(value) || envExp.test(value)) return true const namedColor = ['transparent', 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen'] const valueExp = { number: /^((-?(\d+(\.\d+)?|\.\d+))(rpx|px|%|vw|vh)?|hairlineWidth)$/, color: new RegExp(('^(' + namedColor.join('|') + ')$') + '|(^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$)|^(rgb|rgba|hsl|hsla|hwb)\\(.+\\)$') } const type = getValueType(prop) const tipsType = (type) => { const info = { [ValueType.number]: '2rpx,10%,30rpx', [ValueType.color]: 'rgb,rgba,hsl,hsla,hwb,named color,#000000', [ValueType.enum]: `${SUPPORTED_PROP_VAL_ARR[prop]?.join(',')}` } tips(`Value of ${prop} in ${selector} should be ${type}, eg ${info[type]}, received [${value}], please check again!`) } switch (type) { case ValueType.number: { if (!valueExp.number.test(value)) { tipsType(type) return false } return true } case ValueType.color: { if (!valueExp.color.test(value)) { tipsType(type) return false } return true } case ValueType.enum: { const isIn = SUPPORTED_PROP_VAL_ARR[prop].includes(value) const isType = Object.keys(valueExp).some(item => valueExp[item].test(value) && SUPPORTED_PROP_VAL_ARR[prop].includes(ValueType[item])) if (!isIn && !isType) { tipsType(type) return false } return true } } return true } // prop & value 校验:过滤的不合法的属性和属性值 const verification = ({ prop, value, selector }, { mode }) => { return verifyProps({ prop, value, selector }, { mode }) && verifyValues({ prop, value, selector }) && ({ prop, value }) } // 简写转换规则 const AbbreviationMap = { // 仅支持 offset-x | offset-y | blur-radius | color 排序 'text-shadow': ['textShadowOffset.width', 'textShadowOffset.height', 'textShadowRadius', 'textShadowColor'], // 仅支持 width | style | color 这种排序 border: ['borderWidth', 'borderStyle', 'borderColor'], // 仅支持 width | style | color 这种排序 'border-left': ['borderLeftWidth', 'borderLeftStyle', 'borderLeftColor'], // 仅支持 width | style | color 这种排序 'border-right': ['borderRightWidth', 'borderRightStyle', 'borderRightColor'], // 仅支持 width | style | color 这种排序 'border-top': ['borderTopWidth', 'borderTopStyle', 'borderTopColor'], // 仅支持 width | style | color 这种排序 'border-bottom': ['borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'], // 0.76 及以上版本RN支持 box-shadow,实测0.77版本drn红米note12pro Android12 不支持内阴影,其他表现和web一致 // 仅支持 offset-x | offset-y | blur-radius | color 排序 // 'box-shadow': ['shadowOffset.width', 'shadowOffset.height', 'shadowRadius', 'shadowColor'], // 仅支持 text-decoration-line text-decoration-style text-decoration-color 这种格式 'text-decoration': ['textDecorationLine', 'textDecorationStyle', 'textDecorationColor'], // flex-grow | flex-shrink | flex-basis flex: ['flexGrow', 'flexShrink', 'flexBasis'], // flex-flow: <'flex-direction'> or flex-flow: <'flex-direction'> and <'flex-wrap'> 'flex-flow': ['flexDirection', 'flexWrap'], 'border-radius': ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'], 'border-width': ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'], 'border-color': ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'], margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'], padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] } const formatAbbreviation = ({ prop, value, selector }, { mode }) => { const original = `${prop}:${value}` const props = AbbreviationMap[prop] const values = Array.isArray(value) ? value : parseValues(value) const cssMap = [] // 复合属性不支持单个css var(css var可以接收单个值可以是复合值,复合值运行时不处理,这里前置提示一下) if (values.length === 1 && cssVariableExp.test(value)) { error(`Property ${prop} in ${selector} is abbreviated property and does not support a single CSS var`) return cssMap } let idx = 0 let propsIdx = 0 const diff = values.length - props.length while (idx < values.length) { const prop = props[propsIdx] if (!prop) { warn(`Value of [${original}] in ${selector} has not enough props to assign, please check again!`) break } const value = values[idx] const newProp = hump2dash(prop.replace(/\..+/, '')) if (!verifyProps({ prop: newProp, value, selector }, { mode }, diff === 0)) { // 有 ios or android 不支持的 prop,跳过 prop if (diff === 0) { propsIdx++ idx++ } else { propsIdx++ } } else if (!verifyValues({ prop: newProp, value, selector }, diff === 0)) { // 值不合法 跳过 value if (diff === 0) { propsIdx++ idx++ } else if (diff < 0) { propsIdx++ } else { idx++ } } else if (prop.includes('.')) { // 多个属性值的prop const [main, sub] = prop.split('.') const cssData = cssMap.find(item => item.prop === main) if (cssData) { // 设置过 cssData.value[sub] = value } else { // 第一次设置 cssMap.push({ prop: main, value: { [sub]: value } }) } idx += 1 propsIdx += 1 } else { // 单个值的属性 cssMap.push({ prop, value }) idx += 1 propsIdx += 1 } } return cssMap } const formatCompositeVal = ({ prop, value, selector }, { mode }) => { const values = parseValues(value).splice(0, 4) switch (values.length) { case 1: verifyValues({ prop, value, selector }, false) return { prop, value } case 2: values.push(...values) break case 3: values.push(values[1]) break } return formatAbbreviation({ prop, value: values, selector }, { mode }) } // line-height const formatLineHeight = ({ prop, value, selector }) => { // line-height 0 直接返回 if (+value === 0) { return { prop, value } } return verifyValues({ prop, value, selector }) && ({ prop, value: /^\s*(-?(\d+(\.\d+)?|\.\d+))\s*$/.test(value) ? `${Math.round(value * 100)}%` : value }) } // background 相关属性的转换 Todo // 仅支持以下属性,不支持其他背景相关的属性 // /^((?!(-color)).)*background((?!(-color)).)*$/ 包含background且不包含background-color const checkBackgroundImage = ({ prop, value, selector }, { mode }) => { const bgPropMap = { image: 'background-image', color: 'background-color', size: 'background-size', repeat: 'background-repeat', position: 'background-position', all: 'background' } const urlExp = /url\(["']?(.*?)["']?\)/ const linearExp = /linear-gradient\(.*\)/ switch (prop) { case bgPropMap.image: { // background-image 支持背景图/渐变/css var if (cssVariableExp.test(value) || urlExp.test(value) || linearExp.test(value)) { return { prop, value } } else { error(`Value of ${prop} in ${selector} selector only support value <url()> or <linear-gradient()>, received ${value}, please check again!`) return false } } case bgPropMap.size: { // background-size // 不支持逗号分隔的多个值:设置多重背景!!! // 支持一个值:这个值指定图片的宽度,图片的高度隐式的为 auto // 支持两个值:第一个值指定图片的宽度,第二个值指定图片的高度 if (parseValues(value, ',').length > 1) { // commas are not allowed in values error(`Value of [${bgPropMap.size}] in ${selector} does not support commas, received [${value}], please check again!`) return false } const values = [] parseValues(value).forEach(item => { if (verifyValues({ prop, value: item, selector })) { // 支持 number 值 / container cover auto 枚举 values.push(item) } }) // value 无有效值时返回false return values.length === 0 ? false : { prop, value: values } } case bgPropMap.position: { const values = [] parseValues(value).forEach(item => { if (verifyValues({ prop, value: item, selector })) { // 支持 number 值 / 枚举, center与50%等价 values.push(item === 'center' ? '50%' : item) } else { error(`Value of [${bgPropMap.size}] in ${selector} does not support commas, received [${value}], please check again!`) } }) return { prop, value: values } } case bgPropMap.all: { // background: 仅支持 background-image & background-color & background-repeat if (cssVariableExp.test(value)) { error(`Property [${bgPropMap.all}] in ${selector} is abbreviated property and does not support CSS var`) return false } const bgMap = [] const values = parseValues(value) values.forEach(item => { const url = item.match(urlExp)?.[0] const linerVal = item.match(linearExp)?.[0] if (url) { bgMap.push({ prop: bgPropMap.image, value: url }) } else if (linerVal) { bgMap.push({ prop: bgPropMap.image, value: linerVal }) } else if (verifyValues({ prop: bgPropMap.color, value: item }, false)) { bgMap.push({ prop: bgPropMap.color, value: item }) } else if (verifyValues({ prop: bgPropMap.repeat, value: item, selector }, false)) { bgMap.push({ prop: bgPropMap.repeat, value: item }) } }) return bgMap.length ? bgMap : false } } unsupportedPropError({ prop, value, selector }, { mode }) return false } // transform 转换 const formatTransform = ({ prop, value, selector }, { mode }) => { // css var & 数组直接返回 if (Array.isArray(value) || cssVariableExp.test(value)) return { prop, value } const values = parseValues(value) const transform = [] values.forEach(item => { const match = item.match(/([/\w]+)\((.+)\)/) if (match && match.length >= 3) { let key = match[1] const val = match[2] switch (key) { case 'translateX': case 'translateY': case 'scaleX': case 'scaleY': case 'rotateX': case 'rotateY': case 'rotateZ': case 'rotate': case 'skewX': case 'skewY': case 'perspective': // 单个值处理 // rotate 处理成 rotateZ key = key === 'rotate' ? 'rotateZ' : key transform.push({ [key]: val }) break case 'matrix': transform.push({ [key]: parseValues(val, ',').map(val => +val) }) break case 'translate': case 'scale': case 'skew': case 'translate3d': // x y 支持 z不支持 case 'scale3d': // x y 支持 z不支持 { // 2 个以上的值处理 key = key.replace('3d', '') const vals = parseValues(val, ',').splice(0, 3) // scale(.5) === scaleX(.5) scaleY(.5) if (vals.length === 1 && key === 'scale') { vals.push(vals[0]) } const xyz = ['X', 'Y', 'Z'] transform.push(...vals.map((v, index) => { if (key !== 'rotate' && index > 1) { unsupportedPropError({ prop: `${key}Z`, value, selector }, { mode }) } return { [`${key}${xyz[index] || ''}`]: v.trim() } })) break } case 'translateZ': case 'scaleZ': case 'rotate3d': // x y z angle case 'matrix3d': default: // 不支持的属性处理 unsupportedPropError({ prop, value, selector }, { mode }) break } } else { error(`Property [${prop}] is invalid in ${selector}, received [${value}], please check again!`) } }) return { prop, value: transform } } const isNumber = (value) => { return !isNaN(+value) } const getIntegersFlex = ({ prop, value, selector }) => { if ((isNumber(value) && value >= 0) || cssVariableExp.test(value)) { return { prop, value } } else { error(`Value of [${prop}] in ${selector} accepts any floating point value >= 0, received [${value}], please check again!`) return false } } const formatFlex = ({ prop, value, selector }) => { let values = parseValues(value) // 值大于3 去前三 if (values.length > 3) { warn(`Value of [flex] in ${selector} supports up to three values, received [${value}], please check again!`) values = values.splice(0, 3) } const cssMap = [] // 单个css var 直接设置 flex 属性 if (values.length === 1 && cssVariableExp.test(value)) { return { prop, value } } // 包含枚举值 none initial if (values.includes('initial') || values.includes('none')) { // css flex: initial ===> flex: 0 1 ===> rn flex 0 1 // css flex: none ===> css flex: 0 0 ===> rn flex 0 0 if (values.length === 1) { // 添加 basis 和 shrink // value=initial 则 flexShrink=1,其他场景都是0 cssMap.push(...[{ prop: 'flexGrow', value: 0 }, { prop: 'flexShrink', value: +(values[0] === 'initial') }]) } else { error(`Value of [${prop}] in ${selector} is invalid, When setting the value of flex to none or initial, only one value is supported.`) } return cssMap } // 只有1-2个值且最后的值是flexBasis 的有效值(auto或者有单位百分比、px等) // 在设置 flex basis 有效值的场景下,如果没有设置 grow 和 shrink,则默认为1 // 单值 flex: 1 1 <flex-basis> // 双值 flex: <flex-grow> 1 <flex-basis> // 三值 flex: <flex-grow> <flex-shrink> <flex-basis> for (let i = 0; i < 3; i++) { if (i < 2) { // 添加 grow 和 shrink const isValid = isNumber(values[0]) || cssVariableExp.test(values[0]) // 兜底 1 const val = isValid ? values[0] : 1 const item = getIntegersFlex({ prop: AbbreviationMap[prop][i], value: val, selector }) item && cssMap.push(item) isValid && values.shift() } else { // 添加 flexBasis // 有单位(百分比、px等) 的 value 赋值 flexBasis,auto 不处理,兜底 0 const val = values[0] || 0 if (val !== 'auto') { cssMap.push({ prop: 'flexBasis', value: val }) } } } return cssMap } const formatFontFamily = ({ prop, value, selector }) => { // 去掉引号 取逗号分隔后的第一个 const newVal = value.replace(/"|'/g, '').trim() const values = parseValues(newVal, ',') if (!newVal || !values.length) { error(`Value of [${prop}] is invalid in ${selector}, received [${value}], please check again!`) return false } else if (values.length > 1) { warn(`Value of [${prop}] only supports one in ${selector}, received [${value}], and the first one is used by default.`) } return { prop, value: values[0].trim() } } // const formatBoxShadow = ({ prop, value, selector }, { mode }) => { // value = value.trim() // if (value === 'none') { // return false // } // const cssMap = formatAbbreviation({ prop, value, selector }, { mode }) // if (mode === 'android' || mode === 'harmony') return cssMap // // ios 阴影需要额外设置 shadowOpacity=1 // cssMap.push({ // prop: 'shadowOpacity', // value: 1 // }) // return cssMap // } return { supportedModes: ['ios', 'android', 'harmony'], rules: [ { // 背景相关属性的处理 test: /^(background|background-image|background-size|background-position)$/, ios: checkBackgroundImage, android: checkBackgroundImage, harmony: checkBackgroundImage }, { // margin padding 内外边距的处理 test: /^(margin|padding|border-radius|border-width|border-color)$/, ios: formatCompositeVal, android: formatCompositeVal, harmony: formatCompositeVal }, { // line-height 换算 test: 'line-height', ios: formatLineHeight, android: formatLineHeight, harmony: formatLineHeight }, { test: 'transform', ios: formatTransform, android: formatTransform, harmony: formatTransform }, { test: 'flex', ios: formatFlex, android: formatFlex, harmony: formatFlex }, { test: 'font-family', ios: formatFontFamily, android: formatFontFamily, harmony: formatFontFamily }, // { // test: 'box-shadow', // ios: formatBoxShadow, // android: formatBoxShadow, // harmony: formatBoxShadow // }, // 通用的简写格式匹配 { test: new RegExp('^(' + Object.keys(AbbreviationMap).join('|') + ')$'), ios: formatAbbreviation, android: formatAbbreviation, harmony: formatAbbreviation }, // 属性&属性值校验 { test: () => true, ios: verification, android: verification, harmony: verification } ] } }