UNPKG

postcss-logical-polyfill

Version:

A PostCSS plugin that provides physical property polyfills for CSS logical properties with intelligent direction-aware selector handling, block-direction optimization, and extended logical property support via shim system

549 lines (548 loc) 19.7 kB
import postcss from "postcss"; import logical from "postcss-logical"; function escapeSelector(selector) { return selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function containsCustomSelector(selector, customSelector) { const trimmedCustom = customSelector.trim(); const trimmedSelector = selector.trim(); if (trimmedCustom.match(/^[.#\[]/) || trimmedCustom.startsWith(":")) { const escapedCustom = escapeSelector(trimmedCustom); let pattern; if (trimmedCustom.startsWith(".")) { pattern = `${escapedCustom}(?=\\s|$|[:.\\[#>+~,])`; } else { pattern = `(?:^|\\s)${escapedCustom}(?=\\s|$|[:.\\[#>+~,])`; } return new RegExp(pattern, "g").test(trimmedSelector); } return trimmedSelector.includes(trimmedCustom); } function cleanDirectionSelectors(selector, config = {}) { const builtinPatterns = [ /:dir\(\s*rtl\s*\)/g, /:dir\(\s*ltr\s*\)/g, /\[\s*dir\s*=\s*["']?rtl["']?\s*\]/g, /\[\s*dir\s*=\s*["']?ltr["']?\s*\]/g ]; let cleaned = selector; builtinPatterns.forEach((pattern) => { cleaned = cleaned.replace(pattern, ""); }); [config.ltr, config.rtl].forEach((customSelector) => { if (customSelector) { const escapedCustom = escapeSelector(customSelector); const customRegex = new RegExp(`(^|\\s)${escapedCustom}(?=\\s|$|[:.\\[#>+~,])`, "g"); cleaned = cleaned.replace(customRegex, "$1"); } }); return cleaned.replace(/\s+/g, " ").trim(); } function detectDirection(selector, config = {}) { const builtinPatterns = [ { patterns: [/:dir\(\s*ltr\s*\)/, /\[\s*dir\s*=\s*["']?ltr["']?\s*\]/], direction: "ltr" }, { patterns: [/:dir\(\s*rtl\s*\)/, /\[\s*dir\s*=\s*["']?rtl["']?\s*\]/], direction: "rtl" } ]; const directionMatches = []; builtinPatterns.forEach(({ patterns, direction }) => { patterns.forEach((pattern) => { const matches = selector.matchAll(new RegExp(pattern.source, "g")); for (const match of matches) { if (match.index !== void 0) { directionMatches.push({ direction, position: match.index }); } } }); }); const customSelectors = [ { selector: config.ltr, direction: "ltr" }, { selector: config.rtl, direction: "rtl" } ]; customSelectors.forEach(({ selector: customSelector, direction }) => { if (customSelector && containsCustomSelector(selector, customSelector)) { const match = selector.indexOf(customSelector); if (match !== -1) { directionMatches.push({ direction, position: match }); } } }); if (directionMatches.length === 0) { return "none"; } directionMatches.sort((a, b) => b.position - a.position); return directionMatches[0].direction; } function generateSelector(selector, direction, config = {}) { const currentDirection = detectDirection(selector, config); if (currentDirection === direction) { return selector; } const cleanedSelector = cleanDirectionSelectors(selector, config); let directionSelector; if (direction === "ltr") { directionSelector = config.ltr || '[dir="ltr"]'; } else { directionSelector = config.rtl || '[dir="rtl"]'; } if (!cleanedSelector) { return directionSelector; } return `${directionSelector} ${cleanedSelector}`; } const SHIM_DECLARATIONS = { // Scroll margin logical properties "scroll-margin-inline-start": (decl, { inlineDirection }) => { const prop = inlineDirection === "left-to-right" ? "scroll-margin-left" : "scroll-margin-right"; decl.cloneBefore({ prop }); decl.remove(); }, "scroll-margin-inline-end": (decl, { inlineDirection }) => { const prop = inlineDirection === "left-to-right" ? "scroll-margin-right" : "scroll-margin-left"; decl.cloneBefore({ prop }); decl.remove(); }, "scroll-margin-inline": (decl, { inlineDirection }) => { const values = decl.value.trim().split(/\s+/); const startValue = values[0]; const endValue = values[1] || startValue; if (inlineDirection === "left-to-right") { decl.cloneBefore({ prop: "scroll-margin-left", value: startValue }); decl.cloneBefore({ prop: "scroll-margin-right", value: endValue }); } else { decl.cloneBefore({ prop: "scroll-margin-right", value: startValue }); decl.cloneBefore({ prop: "scroll-margin-left", value: endValue }); } decl.remove(); }, "scroll-margin-block-start": (decl) => { decl.cloneBefore({ prop: "scroll-margin-top" }); decl.remove(); }, "scroll-margin-block-end": (decl) => { decl.cloneBefore({ prop: "scroll-margin-bottom" }); decl.remove(); }, "scroll-margin-block": (decl) => { const values = decl.value.trim().split(/\s+/); const startValue = values[0]; const endValue = values[1] || startValue; decl.cloneBefore({ prop: "scroll-margin-top", value: startValue }); decl.cloneBefore({ prop: "scroll-margin-bottom", value: endValue }); decl.remove(); }, // Scroll padding logical properties "scroll-padding-inline-start": (decl, { inlineDirection }) => { const prop = inlineDirection === "left-to-right" ? "scroll-padding-left" : "scroll-padding-right"; decl.cloneBefore({ prop }); decl.remove(); }, "scroll-padding-inline-end": (decl, { inlineDirection }) => { const prop = inlineDirection === "left-to-right" ? "scroll-padding-right" : "scroll-padding-left"; decl.cloneBefore({ prop }); decl.remove(); }, "scroll-padding-inline": (decl, { inlineDirection }) => { const values = decl.value.trim().split(/\s+/); const startValue = values[0]; const endValue = values[1] || startValue; if (inlineDirection === "left-to-right") { decl.cloneBefore({ prop: "scroll-padding-left", value: startValue }); decl.cloneBefore({ prop: "scroll-padding-right", value: endValue }); } else { decl.cloneBefore({ prop: "scroll-padding-right", value: startValue }); decl.cloneBefore({ prop: "scroll-padding-left", value: endValue }); } decl.remove(); }, "scroll-padding-block-start": (decl) => { decl.cloneBefore({ prop: "scroll-padding-top" }); decl.remove(); }, "scroll-padding-block-end": (decl) => { decl.cloneBefore({ prop: "scroll-padding-bottom" }); decl.remove(); }, "scroll-padding-block": (decl) => { const values = decl.value.trim().split(/\s+/); const startValue = values[0]; const endValue = values[1] || startValue; decl.cloneBefore({ prop: "scroll-padding-top", value: startValue }); decl.cloneBefore({ prop: "scroll-padding-bottom", value: endValue }); decl.remove(); }, // Logical values for existing properties float: (decl, { inlineDirection }) => { if (decl.value === "inline-start") { const value = inlineDirection === "left-to-right" ? "left" : "right"; decl.cloneBefore({ prop: "float", value }); decl.remove(); } else if (decl.value === "inline-end") { const value = inlineDirection === "left-to-right" ? "right" : "left"; decl.cloneBefore({ prop: "float", value }); decl.remove(); } }, clear: (decl, { inlineDirection }) => { if (decl.value === "inline-start") { const value = inlineDirection === "left-to-right" ? "left" : "right"; decl.cloneBefore({ prop: "clear", value }); decl.remove(); } else if (decl.value === "inline-end") { const value = inlineDirection === "left-to-right" ? "right" : "left"; decl.cloneBefore({ prop: "clear", value }); decl.remove(); } }, resize: (decl) => { if (decl.value === "block") { decl.cloneBefore({ prop: "resize", value: "vertical" }); decl.remove(); } else if (decl.value === "inline") { decl.cloneBefore({ prop: "resize", value: "horizontal" }); decl.remove(); } }, // Overscroll behavior logical properties "overscroll-behavior-block": (decl) => { decl.cloneBefore({ prop: "overscroll-behavior-y" }); decl.remove(); }, "overscroll-behavior-inline": (decl, { inlineDirection }) => { decl.cloneBefore({ prop: "overscroll-behavior-x" }); decl.remove(); }, // Overflow logical properties "overflow-block": (decl) => { decl.cloneBefore({ prop: "overflow-y" }); decl.remove(); }, "overflow-inline": (decl) => { decl.cloneBefore({ prop: "overflow-x" }); decl.remove(); }, // CSS Containment logical properties "contain-intrinsic-block-size": (decl) => { decl.cloneBefore({ prop: "contain-intrinsic-height" }); decl.remove(); }, "contain-intrinsic-inline-size": (decl) => { decl.cloneBefore({ prop: "contain-intrinsic-width" }); decl.remove(); } }; function extendProcessors(processors) { Object.entries(SHIM_DECLARATIONS).forEach(([prop, handler]) => { processors.ltr.Declaration[prop] = (decl) => handler(decl, { inlineDirection: "left-to-right" }); processors.rtl.Declaration[prop] = (decl) => handler(decl, { inlineDirection: "right-to-left" }); }); } const EXPERIMENTAL_DECLARATIONS = { // Handle background and background-image properties with linear-gradient logical directions "background": handleGradientProperty, "background-image": handleGradientProperty }; function handleGradientProperty(decl, { inlineDirection }) { const value = decl.value; if (hasGradientWithLogicalDirection(value) && hasLogicalGradientDirection(value)) { const transformedValue = transformLogicalGradient(value, inlineDirection); if (transformedValue !== value) { decl.cloneBefore({ prop: decl.prop, value: transformedValue }); decl.remove(); } } } function hasGradientWithLogicalDirection(value) { const gradientTypes = [ "linear-gradient", "radial-gradient", "repeating-linear-gradient", "repeating-radial-gradient" ]; return gradientTypes.some((type) => value.includes(type)); } function hasLogicalGradientDirection(value) { const logicalKeywords = [ "inline-start", "inline-end", "block-start", "block-end" ]; return logicalKeywords.some( (keyword) => new RegExp(`\\b${keyword}\\b`).test(value) ); } function transformLogicalGradient(value, inlineDirection) { let transformedValue = value; const inlineMapping = inlineDirection === "left-to-right" ? { "inline-start": "left", "inline-end": "right" } : { "inline-start": "right", "inline-end": "left" }; const blockMapping = { "block-start": "top", "block-end": "bottom" }; for (const [logical2, physical] of Object.entries(inlineMapping)) { transformedValue = transformedValue.replace( new RegExp(`\\bto\\s+${logical2}\\b`, "g"), `to ${physical}` ); transformedValue = transformedValue.replace( new RegExp(`\\bat\\s+([^,)]*\\s+)?${logical2}(\\s+[^,)]*)?`, "g"), (match, before = "", after = "") => { return `at ${before}${physical}${after}`; } ); transformedValue = transformedValue.replace( new RegExp(`\\b${logical2}\\b`, "g"), physical ); } for (const [logical2, physical] of Object.entries(blockMapping)) { transformedValue = transformedValue.replace( new RegExp(`\\bto\\s+${logical2}\\b`, "g"), `to ${physical}` ); transformedValue = transformedValue.replace( new RegExp(`\\bat\\s+([^,)]*\\s+)?${logical2}(\\s+[^,)]*)?`, "g"), (match, before = "", after = "") => { return `at ${before}${physical}${after}`; } ); transformedValue = transformedValue.replace( new RegExp(`\\b${logical2}\\b`, "g"), physical ); } return transformedValue; } function extendProcessorsWithExperimental(processors) { Object.entries(EXPERIMENTAL_DECLARATIONS).forEach(([prop, handler]) => { const existingHandler = processors.ltr.Declaration[prop]; const existingRtlHandler = processors.rtl.Declaration[prop]; processors.ltr.Declaration[prop] = (decl) => { handler(decl, { inlineDirection: "left-to-right" }); if (decl.parent && existingHandler) { existingHandler(decl); } }; processors.rtl.Declaration[prop] = (decl) => { handler(decl, { inlineDirection: "right-to-left" }); if (decl.parent && existingRtlHandler) { existingRtlHandler(decl); } }; }); } const PROCESSORS = { ltr: logical({ inlineDirection: "left-to-right" }), rtl: logical({ inlineDirection: "right-to-left" }) }; extendProcessors(PROCESSORS); extendProcessorsWithExperimental(PROCESSORS); const supportedLogicalPropertiesSet = new Set( Object.keys(PROCESSORS.ltr.Declaration || {}) ); function hasLogicalProperties(rule) { return rule.some( (decl) => { if (decl.type !== "decl") return false; return supportedLogicalPropertiesSet.has(decl.prop); } ); } async function applyLogicalTransformation(rule, direction) { const processor = PROCESSORS[direction]; const tempRoot = postcss.root(); tempRoot.append(rule.clone()); try { const transformed = await postcss([processor]).process(tempRoot, { from: void 0 }); let transformedRule = null; transformed.root.walkRules((r) => { transformedRule = r; }); return transformedRule; } catch (error) { console.warn("Failed to process logical properties:", error); return null; } } function extractDeclarations(rule) { const declarations = /* @__PURE__ */ new Map(); rule.each((node) => { if (node.type === "decl") { declarations.set(node.prop, { value: node.value, important: Boolean(node.important) }); } }); return declarations; } function rulesAreIdentical(rule1, rule2) { const decls1 = extractDeclarations(rule1); const decls2 = extractDeclarations(rule2); if (decls1.size !== decls2.size) return false; for (const [prop, decl1] of decls1) { const decl2 = decls2.get(prop); if (!decl2 || decl2.value !== decl1.value || decl2.important !== decl1.important) { return false; } } return true; } function analyzePropertyDifferences(ltrRule, rtlRule) { const ltrProps = extractDeclarations(ltrRule); const rtlProps = extractDeclarations(rtlRule); const commonProps = /* @__PURE__ */ new Map(); const ltrOnlyProps = /* @__PURE__ */ new Map(); const rtlOnlyProps = /* @__PURE__ */ new Map(); ltrProps.forEach((decl, prop) => { const rtlDecl = rtlProps.get(prop); if (rtlDecl && rtlDecl.value === decl.value && rtlDecl.important === decl.important) { commonProps.set(prop, decl); } else { ltrOnlyProps.set(prop, decl); } }); rtlProps.forEach((decl, prop) => { if (!commonProps.has(prop)) { rtlOnlyProps.set(prop, decl); } }); return { commonProps, ltrOnlyProps, rtlOnlyProps }; } const SKIP_AT_RULES = ["keyframes", "font-face", "counter-style", "page"]; const DEFAULT_CONFIG = { rtlSelector: '[dir="rtl"]', ltrSelector: '[dir="ltr"]', outputOrder: "ltr-first" }; function categorizeSelectors(selectors, config) { const categories = { ltr: [], rtl: [], none: [] }; selectors.forEach((selector) => { const direction = detectDirection(selector, config); categories[direction].push(selector); }); return { ltrSelectors: categories.ltr, rtlSelectors: categories.rtl, noscopeSelectors: categories.none }; } function createRuleWithPropertiesAndSelectors(baseRule, selectors, properties, origDecl) { const newRule = baseRule.clone(); newRule.selectors = selectors; newRule.removeAll(); properties.forEach((decl, prop) => { { const foundDecl = baseRule.nodes.find( (node) => node.type === "decl" ); if (foundDecl) { newRule.append(foundDecl.clone({ prop, value: decl.value, important: decl.important })); } else { newRule.append({ prop, value: decl.value, important: decl.important }); } } }); return newRule; } function cloneRuleWithSelectors(baseRule, selectors) { const newRule = baseRule.clone(); newRule.selectors = selectors; return newRule; } function addDirectionRules(results, rules, outputOrder) { const [ltrRule, rtlRule] = outputOrder === "ltr-first" ? rules : [rules[1], rules[0]]; if (ltrRule) results.push(ltrRule); if (rtlRule) results.push(rtlRule); } function createDirectionRule(baseRule, selectors, properties, direction, config, origDecl) { if (properties.size === 0) return null; const scopedSelectors = selectors.map((sel) => generateSelector(sel, direction, config)); return createRuleWithPropertiesAndSelectors(baseRule, scopedSelectors, properties); } function processDirectionSpecificRules(rule, selectors, ltrProps, rtlProps, config, outputOrder) { const directionRules = [ createDirectionRule(rule, selectors, ltrProps, "ltr", config), createDirectionRule(rule, selectors, rtlProps, "rtl", config) ]; const results = []; addDirectionRules(results, directionRules, outputOrder); return results; } async function processRule(rule, ltrSelector, rtlSelector, outputOrder = "ltr-first") { const config = { ltr: ltrSelector, rtl: rtlSelector }; const results = []; const { ltrSelectors, rtlSelectors, noscopeSelectors } = categorizeSelectors(rule.selectors, config); const ltrTransformed = await applyLogicalTransformation(rule, "ltr"); const rtlTransformed = await applyLogicalTransformation(rule, "rtl"); if (!ltrTransformed || !rtlTransformed) { return []; } if (noscopeSelectors.length > 0) { if (rulesAreIdentical(ltrTransformed, rtlTransformed)) { results.push(cloneRuleWithSelectors(ltrTransformed, noscopeSelectors)); } else { const { commonProps, ltrOnlyProps, rtlOnlyProps } = analyzePropertyDifferences(ltrTransformed, rtlTransformed); if (commonProps.size > 0) { results.push(createRuleWithPropertiesAndSelectors(rule, noscopeSelectors, commonProps)); } results.push(...processDirectionSpecificRules(rule, noscopeSelectors, ltrOnlyProps, rtlOnlyProps, config, outputOrder)); } } const scopedRules = [ { selectors: ltrSelectors, transformedRule: ltrTransformed }, { selectors: rtlSelectors, transformedRule: rtlTransformed } ]; scopedRules.forEach(({ selectors, transformedRule }) => { if (selectors.length > 0) { results.push(cloneRuleWithSelectors(transformedRule, selectors)); } }); return results; } const logicalPolyfill = (opts = {}) => { var _a, _b; const rtlSelector = ((_a = opts.rtl) == null ? void 0 : _a.selector) || DEFAULT_CONFIG.rtlSelector; const ltrSelector = ((_b = opts.ltr) == null ? void 0 : _b.selector) || DEFAULT_CONFIG.ltrSelector; const outputOrder = opts.outputOrder || DEFAULT_CONFIG.outputOrder; return { postcssPlugin: "postcss-logical-polyfill", async Once(root) { await processAllRules(root, ltrSelector, rtlSelector, outputOrder); } }; }; async function processAllRules(container, ltrSelector, rtlSelector, outputOrder) { const rulesToProcess = []; const promises = []; container.each((node) => { if (node.type === "rule") { const hasLogical = hasLogicalProperties(node); if (hasLogical) rulesToProcess.push(node); } else if (node.type === "atrule") { if (SKIP_AT_RULES.includes(node.name)) return; promises.push(processAllRules(node, ltrSelector, rtlSelector, outputOrder)); } }); await Promise.all(promises); for (const rule of rulesToProcess) { const processedRules = await processRule(rule, ltrSelector, rtlSelector, outputOrder); if (processedRules.length > 0) { processedRules.forEach((newRule) => { container.insertBefore(rule, newRule); }); rule.remove(); } } } logicalPolyfill.postcss = true; export { logicalPolyfill as default };