postcss-px-morph
Version:
A flexible PostCSS plugin to transform px to rem, vw, or a hybrid of both, with advanced configuration.
230 lines (229 loc) • 10.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.defaultOptions = void 0;
const converter_1 = require("./converter");
const minimatch_1 = require("minimatch");
exports.defaultOptions = {
mode: 'rem',
rootValue: 16,
viewportWidth: 375,
unitPrecision: 5,
minPixelValue: 1,
hybridOptions: {
defaultMode: 'rem',
remProperties: [],
vwProperties: [],
},
minusPxToMinusMode: true,
include: ['**/*.css', '**/*.scss', '**/*.less', '**/*.styl', '**/*.stylus', '**/*.sass', '**/*.vue'],
exclude: [
'**/*.min.css', '**/*.min.scss', '**/*.min.less', '**/*.min.styl', '**/*.min.stylus', '**/*.min.sass',
'**/*.min.vue', '**/*.min.js', '**/*.min.ts', '**/*.min.tsx', '**/*.min.jsx', '**/*.min.json', '**/*.min.html',
'**/*.min.md', '**/*.min.txt', '**/*.min.xml', '**/*.min.yaml', '**/*.min.yml', '**/*.min.toml', '**/*.min.ini',
'**/*.min.conf', '**/*.min.config', '**/*.min.properties', '**/*.min.env', '**/*.min.env.local', '**/*.min.env.development',
'**/*.min.env.production', '**/*.min.env.test', 'node_modules/**/*'
],
enabled: true,
};
// ----------------------Helper Functions ----------------------
const pxRegex = /"([^"\n]*)"|'([^'\n]*)'|url\([^)\n]*\)|(\d*\.?\d+)px/g;
const sanitizeProperty = (prop) => {
// 限制属性名长度,防止ReDoS
const MAX_LENGTH = 100;
if (prop.length > MAX_LENGTH) {
return prop.substring(0, MAX_LENGTH);
}
// 只允许字母、数字、连字符和下划线
return prop.replace(/[^a-zA-Z0-9-_]/g, '');
};
const isPropMatch = (prop, properties) => {
if (!prop || typeof prop !== 'string')
return false;
const sanitizedProp = sanitizeProperty(prop.toLowerCase());
if (!sanitizedProp)
return false;
return properties.some(pattern => {
if (!pattern || typeof pattern !== 'string')
return false;
const sanitizedPattern = sanitizeProperty(pattern.toLowerCase());
if (!sanitizedPattern)
return false;
if (sanitizedPattern === '*') {
return true;
}
// 限制通配符使用,防止复杂模式
const wildcardCount = (sanitizedPattern.match(/\*/g) || []).length;
if (wildcardCount > 2)
return false; // 最多允许2个通配符
if (sanitizedPattern.startsWith('*') && sanitizedPattern.endsWith('*')) {
// *middle* 模式
const middle = sanitizedPattern.slice(1, -1);
return sanitizedProp.includes(middle);
}
else if (sanitizedPattern.startsWith('*')) {
// *suffix 模式
const suffix = sanitizedPattern.slice(1);
return sanitizedProp.endsWith(suffix);
}
else if (sanitizedPattern.endsWith('*')) {
// prefix* 模式
const prefix = sanitizedPattern.slice(0, -1);
return sanitizedProp.startsWith(prefix);
}
else if (sanitizedPattern.includes('*')) {
// prefix*suffix 模式
const parts = sanitizedPattern.split('*');
if (parts.length === 2) {
const [prefix, suffix] = parts;
return sanitizedProp.startsWith(prefix) && sanitizedProp.endsWith(suffix);
}
}
// 精确匹配
return sanitizedProp === sanitizedPattern;
});
};
const isPxIgnore = (decl) => {
var _a;
const comment = decl.next();
if ((comment === null || comment === void 0 ? void 0 : comment.type) === 'comment') {
return (_a = comment.text) === null || _a === void 0 ? void 0 : _a.includes('px-ignore');
}
return false;
};
const isMinusPx = (decl) => {
// 1. 如果整个值就是一个负长度,例如 "-16px"
// 2. 如果里面出现了独立的负长度,例如 "-16px 0 0 -8px"
// 3. 但放过 calc(...) 里的减号
const hasNegativeToken = /(^|[^\w.)])-\d+(?:\.\d+)?px($|[^\w(])/i.test(decl.value);
if (hasNegativeToken) {
// 不开启负值转化,是负值,不转化
return true;
}
return false;
};
// ----------------------Post CSS Plugin ----------------------
const Plugin = (options = { mode: 'rem' }) => {
// 输入验证和清理 - 使用默认值替代错误抛出
const validateOptions = (options) => {
const opts = { ...exports.defaultOptions, ...options };
// 验证数值参数 - 使用默认值或修正值
if (!Number.isFinite(opts.rootValue) || opts.rootValue <= 0) {
opts.rootValue = 16; // 使用默认值
}
if (!Number.isFinite(opts.viewportWidth) || opts.viewportWidth <= 0) {
opts.viewportWidth = 375; // 使用默认值
}
if (!Number.isFinite(opts.unitPrecision) || opts.unitPrecision < 0 || opts.unitPrecision > 20) {
opts.unitPrecision = Math.max(0, Math.min(20, Math.round(opts.unitPrecision || 5)));
}
if (!Number.isFinite(opts.minPixelValue) || opts.minPixelValue < 0) {
opts.minPixelValue = 1; // 使用默认值
}
// 验证mode - 使用默认值
if (!['rem', 'vw', 'hybrid'].includes(opts.mode)) {
opts.mode = 'rem';
}
// 验证hybridOptions - 清理无效值
if (opts.hybridOptions) {
if (opts.hybridOptions.defaultMode && !['rem', 'vw'].includes(opts.hybridOptions.defaultMode)) {
opts.hybridOptions.defaultMode = 'rem';
}
// 清理属性数组
const sanitizePropertyArray = (arr) => {
if (!Array.isArray(arr))
return [];
return arr.filter(item => typeof item === 'string' && item.length > 0).slice(0, 100); // 限制数组大小
};
opts.hybridOptions.remProperties = sanitizePropertyArray(opts.hybridOptions.remProperties || []);
opts.hybridOptions.vwProperties = sanitizePropertyArray(opts.hybridOptions.vwProperties || []);
}
// 清理include/exclude数组
const sanitizePatternArray = (arr) => {
if (!Array.isArray(arr))
return [];
return arr.filter(item => typeof item === 'string' && item.length > 0).slice(0, 100); // 限制数组大小
};
opts.include = sanitizePatternArray(opts.include);
opts.exclude = sanitizePatternArray(opts.exclude);
return opts;
};
const opts = validateOptions(options);
return {
postcssPlugin: 'px-morph',
Once(root, { result }) {
var _a, _b;
// 获取文件路径
const filePath = (_b = (_a = root.source) === null || _a === void 0 ? void 0 : _a.input) === null || _b === void 0 ? void 0 : _b.file;
if (!filePath)
return;
// 不在包含中与在排除中,则都不转换
if (opts.include && !opts.include.some(pattern => (0, minimatch_1.minimatch)(filePath, pattern)))
return;
if (opts.exclude && opts.exclude.some(pattern => (0, minimatch_1.minimatch)(filePath, pattern)))
return;
// 转换开始了
root.walkDecls(decl => {
// 判断是否包含px
if (!decl.value.includes('px'))
return;
// px-ignore 不转换,value中获取不到注释内容
if (isPxIgnore(decl))
return;
// 判断是否开启了负值的转化 开启了负值 平且 是负值 负值去转化
if (!opts.minusPxToMinusMode) {
if (isMinusPx(decl))
return;
}
// 替换掉px,转换为rem或vw
const newValue = decl.value.replace(pxRegex, (match, str1, str2, px) => {
// 如果匹配到的是引号中的内容、url() 或 px 值为空,则忽略
if (str1 || str2 || !px)
return match;
// 严格验证px值,防止注入
const pxValue = parseFloat(px);
if (!Number.isFinite(pxValue) || pxValue < 0)
return match;
if (pxValue < opts.minPixelValue)
return match;
let unit;
if (opts.mode === 'hybrid' && opts.hybridOptions) {
// 默认情况,或者没有书写的属性,都会转换为rem
let unitType = opts.hybridOptions.defaultMode || 'rem';
// 如果属性在vwProperties中,则转换为vw
if (isPropMatch(decl.prop, opts.hybridOptions.vwProperties || [])) {
unitType = 'vw';
}
// 如果属性在remProperties中,则转换为rem
if (isPropMatch(decl.prop, opts.hybridOptions.remProperties || [])) {
unitType = 'rem';
}
unit = unitType;
}
else {
unit = opts.mode;
}
try {
if (unit === 'rem') {
return (0, converter_1.pxToRem)(pxValue, opts.rootValue, opts.unitPrecision);
}
else { // vw
return (0, converter_1.pxToVw)(pxValue, opts.viewportWidth, opts.unitPrecision);
}
}
catch (error) {
// 转换失败时返回原值
return match;
}
});
// 如果转换后的值与原值不同,则替换
if (newValue !== decl.value) {
decl.value = newValue;
}
});
}
};
};
exports.default = Plugin;
// 兼容让插件在 require('postcss-px-morph') 中使用
module.exports = Plugin;
module.exports.default = Plugin;