@wix/css-property-parser
Version:
A comprehensive TypeScript library for parsing and serializing CSS property values with full MDN specification compliance
331 lines (330 loc) • 12 kB
JavaScript
// CSS margin property parser - Phase 3 refactored
// Handles parsing of CSS margin shorthand property according to MDN specification
// https://developer.mozilla.org/en-US/docs/Web/CSS/margin
import { parse as parseLength, toCSSValue as lengthToCSSValue } from './length.js';
import { parse as parsePercentage, toCSSValue as percentageToCSSValue } from './percentage.js';
import { parse as parseCSSVariable, toCSSValue as cssVariableToCSSValue } from './css-variable.js';
import { isCssVariable, isGlobalKeyword, tokenize, expandShorthandValues, getValidKeyword } from '../utils/shared-utils.js';
// Import and re-export centralized types
import { ANCHOR_SIZE_KEYWORDS } from '../types.js';
// ========================================
// Utilities
// ========================================
/**
* Checks if a string is a valid margin value component
*/
function isMarginValue(value) {
const trimmed = value.trim();
// CSS variables are valid margin values
if (isCssVariable(trimmed)) {
return true;
}
// anchor-size() function is valid
if (trimmed.startsWith('anchor-size(') && trimmed.endsWith(')') && trimmed.length > 12) {
return true;
}
// Global keywords and auto are valid
if (isGlobalKeyword(trimmed) || trimmed.toLowerCase() === 'auto') {
return true;
}
// Try to parse as percentage
const percentageResult = parsePercentage(trimmed);
if (percentageResult) {
return true; // Margin allows negative percentages
}
// Try to parse as length
const lengthResult = parseLength(trimmed);
if (lengthResult) {
return true; // Margin allows negative lengths
}
return false;
}
/**
* Parses a single margin value into atomic type
*/
function parseMarginValue(value) {
const trimmed = value.trim();
// Handle CSS variables in individual components
if (isCssVariable(trimmed)) {
return parseCSSVariable(trimmed);
}
// Handle anchor-size() function
if (trimmed.startsWith('anchor-size(') && trimmed.endsWith(')') && trimmed.length > 12) {
const expression = trimmed.slice(12, -1).trim(); // Remove 'anchor-size(' and ')'
if (expression === '') {
// anchor-size() with no arguments is valid
return {
function: 'anchor-size'
};
}
// Parse arguments: [ <anchor-name> || <anchor-size> ]? , <length-percentage>?
const args = expression.split(',').map(arg => arg.trim());
if (args.length > 2) {
return null; // Too many arguments
}
let anchorName;
let sizeKeyword;
let fallback;
if (args.length === 1) {
// Either anchor-name/size-keyword or fallback
const arg = args[0];
const validKeyword = getValidKeyword(arg, ANCHOR_SIZE_KEYWORDS);
if (validKeyword) {
sizeKeyword = validKeyword;
}
else if (arg.startsWith('--')) {
anchorName = arg;
}
else {
// Try to parse as length-percentage fallback
const percentageResult = parsePercentage(arg);
if (percentageResult) {
fallback = percentageResult;
}
else {
const lengthResult = parseLength(arg);
if (lengthResult) {
fallback = lengthResult;
}
else {
return null; // Invalid argument
}
}
}
}
else if (args.length === 2) {
// First arg: anchor-name or size-keyword, second arg: fallback
const firstArg = args[0];
const secondArg = args[1];
const validFirstKeyword = getValidKeyword(firstArg, ANCHOR_SIZE_KEYWORDS);
if (validFirstKeyword) {
sizeKeyword = validFirstKeyword;
}
else if (firstArg.startsWith('--')) {
anchorName = firstArg;
}
else {
return null; // Invalid first argument
}
// Second argument should be fallback
const percentageResult = parsePercentage(secondArg);
if (percentageResult) {
fallback = percentageResult;
}
else {
const lengthResult = parseLength(secondArg);
if (lengthResult) {
fallback = lengthResult;
}
else {
return null; // Invalid fallback
}
}
}
return {
function: 'anchor-size',
anchorName,
sizeKeyword,
fallback
};
}
// First validate the value
if (!isMarginValue(trimmed)) {
return null;
}
// Handle keywords (global keywords + auto)
if (isGlobalKeyword(trimmed)) {
return { type: 'keyword', keyword: trimmed.toLowerCase() };
}
if (trimmed.toLowerCase() === 'auto') {
return { type: 'keyword', keyword: 'auto' };
}
// Try percentage first
const percentageResult = parsePercentage(trimmed);
if (percentageResult) {
return percentageResult;
}
// Try length
const lengthResult = parseLength(trimmed);
if (lengthResult) {
return lengthResult;
}
return null;
}
// ========================================
// Main Functions
// ========================================
/**
* Parses a CSS margin property value into structured components
* @param value - The CSS margin property string
* @returns Parsed margin object with individual side values or null if invalid
*/
export function parse(value) {
if (!value || typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
// CSS variables can be parsed directly for entire shorthand
// Try to parse as CSS variable first, but only if it's valid
const cssVariableResult = parseCSSVariable(trimmed);
if (cssVariableResult) {
return cssVariableResult;
}
// Handle single global keyword or auto
if (isGlobalKeyword(trimmed)) {
const keyword = { type: 'keyword', keyword: trimmed.toLowerCase() };
return {
marginTop: keyword,
marginRight: keyword,
marginBottom: keyword,
marginLeft: keyword
};
}
if (trimmed.toLowerCase() === 'auto') {
const keyword = { type: 'keyword', keyword: 'auto' };
return {
marginTop: keyword,
marginRight: keyword,
marginBottom: keyword,
marginLeft: keyword
};
}
// Use shared tokenize utility to handle calc expressions properly
const values = tokenize(trimmed);
if (values.length === 0 || values.length > 4) {
return null;
}
// Validate and parse each value
const parsedValues = [];
for (const val of values) {
const parsedValue = parseMarginValue(val);
if (!parsedValue) {
return null;
}
parsedValues.push(parsedValue);
}
// Expand values according to CSS margin shorthand rules using shared utility
const expandedStrings = expandShorthandValues(values);
const [topStr, rightStr, bottomStr, leftStr] = expandedStrings;
// Parse each expanded value
const marginTop = parseMarginValue(topStr);
const marginRight = parseMarginValue(rightStr);
const marginBottom = parseMarginValue(bottomStr);
const marginLeft = parseMarginValue(leftStr);
if (!marginTop || !marginRight || !marginBottom || !marginLeft) {
return null;
}
return {
marginTop,
marginRight,
marginBottom,
marginLeft
};
}
/**
* Converts a parsed margin back to a CSS value string
* @param parsed - The parsed margin object
* @returns CSS value string or null if invalid
*/
export function toCSSValue(parsed) {
if (!parsed) {
return null;
}
// Handle CSS variables for entire shorthand
if ('CSSvariable' in parsed) {
return cssVariableToCSSValue(parsed);
}
// Handle single atomic values (apply to all sides)
if (!('marginTop' in parsed)) {
const singleValue = parsed;
return convertMarginValueToCSSValue(singleValue);
}
// Handle expanded margin (MarginExpanded)
const marginExpanded = parsed;
// Check if we have the required expanded structure
if (!marginExpanded.marginTop || !marginExpanded.marginRight ||
!marginExpanded.marginBottom || !marginExpanded.marginLeft) {
return null;
}
const topValue = convertMarginValueToCSSValue(marginExpanded.marginTop);
const rightValue = convertMarginValueToCSSValue(marginExpanded.marginRight);
const bottomValue = convertMarginValueToCSSValue(marginExpanded.marginBottom);
const leftValue = convertMarginValueToCSSValue(marginExpanded.marginLeft);
if (!topValue || !rightValue || !bottomValue || !leftValue) {
return null;
}
// Try to recreate the most compact form
// All sides same
if (topValue === rightValue && rightValue === bottomValue && bottomValue === leftValue) {
return topValue;
}
// Vertical/horizontal pattern
if (topValue === bottomValue && rightValue === leftValue) {
return `${topValue} ${rightValue}`;
}
// Top + horizontal + bottom pattern
if (rightValue === leftValue) {
return `${topValue} ${rightValue} ${bottomValue}`;
}
// All different
return `${topValue} ${rightValue} ${bottomValue} ${leftValue}`;
}
/**
* Converts a single margin value back to CSS string
*/
function convertMarginValueToCSSValue(marginValue) {
// Handle CSS variables in components
if ('CSSvariable' in marginValue) {
return cssVariableToCSSValue(marginValue);
}
// Handle keywords
if ('keyword' in marginValue) {
return marginValue.keyword;
}
// Handle anchor-size function
if ('function' in marginValue && marginValue.function === 'anchor-size') {
const anchorFunc = marginValue;
const args = [];
if (anchorFunc.anchorName) {
args.push(anchorFunc.anchorName);
}
if (anchorFunc.sizeKeyword) {
args.push(anchorFunc.sizeKeyword);
}
if (anchorFunc.fallback) {
// Convert fallback to CSS string - handle length, percentage, and calc expressions
let fallbackStr = null;
if ('CSSvariable' in anchorFunc.fallback) {
fallbackStr = cssVariableToCSSValue(anchorFunc.fallback);
}
else if ('expression' in anchorFunc.fallback && 'function' in anchorFunc.fallback) {
// Handle calc, min, max, clamp expressions
const calcFunc = anchorFunc.fallback;
fallbackStr = `${calcFunc.function}(${calcFunc.expression})`;
}
else if ('unit' in anchorFunc.fallback && anchorFunc.fallback.unit === '%') {
fallbackStr = percentageToCSSValue(anchorFunc.fallback);
}
else if ('value' in anchorFunc.fallback && 'unit' in anchorFunc.fallback) {
fallbackStr = lengthToCSSValue(anchorFunc.fallback);
}
if (fallbackStr) {
args.push(fallbackStr);
}
}
return `anchor-size(${args.join(', ')})`;
}
// Handle length/percentage by delegating to the appropriate toCSSValue function
const lengthValue = lengthToCSSValue(marginValue);
if (lengthValue) {
return lengthValue;
}
const percentageValue = percentageToCSSValue(marginValue);
if (percentageValue) {
return percentageValue;
}
return null;
}