|
| 1 | +/** |
| 2 | + * 仅在发布构建链路中(内存里)把组件 SCSS 声明值里的裸 Npx 转为 scale-px(Npx), |
| 3 | + * 与 .cursor/skills/nutui-proportional-scaling/SKILL.md 约定一致;不修改磁盘上的源文件。 |
| 4 | + * |
| 5 | + * 规则摘要: |
| 6 | + * - 仅处理 Declaration,不处理 @media 等 at-rule 参数(避免断点被 scale)。 |
| 7 | + * - 跳过 font-size、font、以及自定义属性 --*。 |
| 8 | + * - 整段保留 scale-font-px(...) / scale-icon-px(...);已写的 scale-px(...) 不嵌套。 |
| 9 | + * - 数值 0 的 px → 字面量 0(不写 scale-px(0px))。 |
| 10 | + * - 已是 calc(Npx * var(--nut-scale-f,...)) 的不再包 scale-px。 |
| 11 | + * - 含 Sass 变量 $ 且含除法 / 的 calc(...) 整段先占位再替换 px(避免 calc($var / 2) 被 postcss-scss 拆坏)。 |
| 12 | + */ |
| 13 | +const postcss = require('postcss') |
| 14 | +const postcssScss = require('postcss-scss') |
| 15 | + |
| 16 | +const PROP_SKIP = new Set(['font-size', 'font']) |
| 17 | + |
| 18 | +function matchingCloseParen(str, openParenIdx) { |
| 19 | + let depth = 1 |
| 20 | + for (let i = openParenIdx + 1; i < str.length; i++) { |
| 21 | + const c = str[i] |
| 22 | + if (c === '(') depth++ |
| 23 | + else if (c === ')') { |
| 24 | + depth-- |
| 25 | + if (depth === 0) return i |
| 26 | + } |
| 27 | + } |
| 28 | + return -1 |
| 29 | +} |
| 30 | + |
| 31 | +/** 最左侧的「体内不含 calc(」的 calc 块(同一层里先处理最左) */ |
| 32 | +function findInnermostCalcRange(str) { |
| 33 | + let best = null |
| 34 | + let scan = 0 |
| 35 | + const lower = str.toLowerCase() |
| 36 | + while (true) { |
| 37 | + const idx = lower.indexOf('calc(', scan) |
| 38 | + if (idx === -1) break |
| 39 | + const openParen = idx + 4 |
| 40 | + const close = matchingCloseParen(str, openParen) |
| 41 | + if (close < 0) { |
| 42 | + scan = idx + 5 |
| 43 | + continue |
| 44 | + } |
| 45 | + const body = str.slice(openParen + 1, close) |
| 46 | + if (!body.toLowerCase().includes('calc(')) { |
| 47 | + if (!best || idx < best.start) { |
| 48 | + best = { start: idx, end: close + 1, body } |
| 49 | + } |
| 50 | + } |
| 51 | + scan = idx + 5 |
| 52 | + } |
| 53 | + return best |
| 54 | +} |
| 55 | + |
| 56 | +/** 体内同时有 $ 与 / 时整段保护(典型:calc(#{$var} / 2)) */ |
| 57 | +function shouldProtectCalcBody(body) { |
| 58 | + return /\$/.test(body) && /\//.test(body) |
| 59 | +} |
| 60 | + |
| 61 | +function protectCalcsForPxPass(value) { |
| 62 | + const saved = [] |
| 63 | + let v = value |
| 64 | + while (true) { |
| 65 | + const m = findInnermostCalcRange(v) |
| 66 | + if (!m) break |
| 67 | + if (!shouldProtectCalcBody(m.body)) break |
| 68 | + saved.push(v.slice(m.start, m.end)) |
| 69 | + v = `${v.slice(0, m.start)}__NUT_CALC_${saved.length - 1}__${v.slice(m.end)}` |
| 70 | + } |
| 71 | + return { value: v, saved } |
| 72 | +} |
| 73 | + |
| 74 | +function restoreCalcs(value, saved) { |
| 75 | + let out = value |
| 76 | + for (let i = saved.length - 1; i >= 0; i--) { |
| 77 | + out = out.split(`__NUT_CALC_${i}__`).join(saved[i]) |
| 78 | + } |
| 79 | + return out |
| 80 | +} |
| 81 | + |
| 82 | +function wrapBarePxInSegment(seg) { |
| 83 | + return seg.replace( |
| 84 | + /(-?\d*\.?\d+)px\b(?!\s*\*\s*var\(\s*--nut-scale-f)/g, |
| 85 | + (full, numStr) => { |
| 86 | + const n = parseFloat(numStr) |
| 87 | + if (!Number.isFinite(n) || n === 0) return '0' |
| 88 | + return `scale-px(${numStr}px)` |
| 89 | + }, |
| 90 | + ) |
| 91 | +} |
| 92 | + |
| 93 | +/** 在非 scale-px(...) 段内包裸 px */ |
| 94 | +function transformScalePxChunks(chunk) { |
| 95 | + return chunk.split(/(\bscale-px\s*\([^)]*\))/g).map((part, i) => { |
| 96 | + if (i % 2 === 1) return part |
| 97 | + return wrapBarePxInSegment(part) |
| 98 | + }).join('') |
| 99 | +} |
| 100 | + |
| 101 | +function transformDeclValue(value) { |
| 102 | + if (value == null || !/[\d.]+\s*px/i.test(value)) return value |
| 103 | + const { value: v1, saved } = protectCalcsForPxPass(value) |
| 104 | + let out = v1 |
| 105 | + .split(/(\bscale-(?:font|icon)-px\s*\([^)]*\))/g) |
| 106 | + .map((outer, oi) => { |
| 107 | + if (oi % 2 === 1) return outer |
| 108 | + return transformScalePxChunks(outer) |
| 109 | + }) |
| 110 | + .join('') |
| 111 | + return restoreCalcs(out, saved) |
| 112 | +} |
| 113 | + |
| 114 | +function pxToScalePxInComponentScssPlugin() { |
| 115 | + return { |
| 116 | + postcssPlugin: 'nutui-px-to-scale-px-in-component-scss', |
| 117 | + Once(root) { |
| 118 | + root.walkDecls((decl) => { |
| 119 | + const prop = decl.prop.toLowerCase() |
| 120 | + if (PROP_SKIP.has(prop)) return |
| 121 | + if (decl.prop.startsWith('--')) return |
| 122 | + decl.value = transformDeclValue(decl.value) |
| 123 | + }) |
| 124 | + }, |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +function pxToScalePxInComponentScss(source) { |
| 129 | + const result = postcss([pxToScalePxInComponentScssPlugin()]).process(source, { |
| 130 | + from: undefined, |
| 131 | + syntax: postcssScss, |
| 132 | + }) |
| 133 | + return result.css |
| 134 | +} |
| 135 | + |
| 136 | +module.exports = pxToScalePxInComponentScss |
| 137 | +module.exports.pxToScalePxInComponentScss = pxToScalePxInComponentScss |
0 commit comments