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
JavaScript
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
};