@wix/css-property-parser
Version:
A comprehensive TypeScript library for parsing and serializing CSS property values with full MDN specification compliance
313 lines (312 loc) • 10.4 kB
JavaScript
/**
* Shared utility constants and functions for CSS property parsing
*/
/**
* Global CSS keywords that are valid for all CSS properties
* Includes all standard global keywords per CSS specification
*/
export const GLOBAL_KEYWORDS = ['inherit', 'initial', 'unset', 'revert', 'revert-layer'];
/**
* Checks if a string is a global CSS keyword
*/
export function isGlobalKeyword(value) {
return GLOBAL_KEYWORDS.includes(value.toLowerCase());
}
/**
* Generic type-safe helper to check if a value is in a readonly keyword array
* Replaces the need for "as any" throughout the codebase
*
* @param value - The string value to check
* @param keywords - The readonly array of valid keywords
* @returns Type predicate indicating if value is one of the keywords
*/
export function isKeywordInArray(value, keywords) {
return keywords.includes(value.toLowerCase());
}
/**
* Type-safe helper to get a validated keyword from an array
* Returns the lowercased keyword if valid, null otherwise
*
* @param value - The string value to validate
* @param keywords - The readonly array of valid keywords
* @returns The validated keyword or null
*/
export function getValidKeyword(value, keywords) {
const lowerValue = value.toLowerCase();
return keywords.includes(lowerValue) ? lowerValue : null;
}
/**
* CSS variable pattern - var(--property-name) or var(--property-name, fallback)
*/
const CSS_VARIABLE_REGEX = /^var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*(.+?))?\s*\)$/i;
/**
* Check if a value is a CSS variable
*/
export function isCssVariable(value) {
return CSS_VARIABLE_REGEX.test(value);
}
/**
* Check if a parsed length or percentage value is non-negative
* Used for CSS properties that don't allow negative values
*
* @param parsed - The parsed length or percentage value
* @returns true if non-negative, false otherwise
*/
export function isNonNegative(parsed) {
if (!parsed)
return false;
// Skip expressions - they can't be validated statically
if ('expression' in parsed) {
return true;
}
// Check length values (have unit property)
if ('unit' in parsed && parsed.unit !== '%') {
return parsed.value >= 0;
}
// Check percentage values (have unit property with '%')
if ('unit' in parsed && parsed.unit === '%') {
return parsed.value >= 0;
}
return false;
}
/**
* Advanced tokenizer for CSS property values
* Handles function calls, quoted strings, and nested structures
*
* @param value - The CSS property value to tokenize
* @returns Array of tokens
*/
export function tokenize(value) {
const tokens = [];
let current = '';
let inFunction = 0;
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
current += char;
}
else if (inQuotes && char === quoteChar) {
inQuotes = false;
current += char;
}
else if (!inQuotes && char === '(') {
inFunction++;
current += char;
}
else if (!inQuotes && char === ')') {
inFunction--;
current += char;
}
else if (!inQuotes && inFunction === 0 && /\s/.test(char)) {
if (current.trim()) {
tokens.push(current.trim());
current = '';
}
}
else {
current += char;
}
}
if (current.trim()) {
tokens.push(current.trim());
}
return tokens;
}
/**
* Splits a CSS value by commas while respecting function boundaries
* Useful for parsing multi-value properties like background
*
* @param value - The CSS property value to split
* @returns Array of comma-separated values
*/
export function splitByComma(value) {
const result = [];
let current = '';
let depth = 0;
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
}
else if (inQuotes && char === quoteChar) {
inQuotes = false;
}
else if (!inQuotes && char === '(') {
depth++;
}
else if (!inQuotes && char === ')') {
depth--;
}
else if (!inQuotes && char === ',' && depth === 0) {
result.push(current.trim());
current = '';
continue;
}
current += char;
}
if (current.trim()) {
result.push(current.trim());
}
return result;
}
/**
* Splits a string on specific delimiter while respecting function boundaries
*
* @param value - The CSS property value to split
* @param delimiter - The delimiter to split on (default: '/')
* @returns Array of split values
*/
export function splitOnDelimiter(value, delimiter = '/') {
const result = [];
let current = '';
let depth = 0;
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
}
else if (inQuotes && char === quoteChar) {
inQuotes = false;
}
else if (!inQuotes && char === '(') {
depth++;
}
else if (!inQuotes && char === ')') {
depth--;
}
else if (!inQuotes && char === delimiter && depth === 0) {
result.push(current.trim());
current = '';
continue;
}
current += char;
}
if (current.trim()) {
result.push(current.trim());
}
return result;
}
/**
* Generic shorthand expansion for CSS properties that follow 1-4 value patterns
* 1 value: applies to all sides
* 2 values: top-bottom, left-right (for margin/padding) or top-left-bottom-right, top-right-bottom-left (for border-radius)
* 3 values: top, left-right, bottom (for margin/padding) or top-left, top-right-bottom-left, bottom-right (for border-radius)
* 4 values: top, right, bottom, left (clockwise)
*/
export function expandShorthandValues(values) {
switch (values.length) {
case 1:
return [values[0], values[0], values[0], values[0]];
case 2:
return [values[0], values[1], values[0], values[1]];
case 3:
return [values[0], values[1], values[2], values[1]];
case 4:
return [values[0], values[1], values[2], values[3]];
default:
throw new Error('Invalid number of shorthand values');
}
}
/**
* Parse CSS function expressions (calc, min, max, clamp)
* Returns a structured expression object for supported functions
*
* @param value - The CSS value to parse
* @returns CSSFunctionExpression if valid function, null otherwise
*/
export function parseCSSFunction(value) {
const trimmed = value.trim();
if (trimmed.match(/^[a-zA-Z-]+\(.+\)$/)) {
const funcMatch = trimmed.match(/^([a-zA-Z-]+)\(.+\)$/);
if (funcMatch) {
const functionType = funcMatch[1].toLowerCase();
// Check if it's a supported function type
const supportedFunctions = ['calc', 'min', 'max', 'clamp'];
if (supportedFunctions.includes(functionType)) {
return {
type: 'expression',
functionType: functionType,
value: trimmed
};
}
}
}
return null;
}
/**
* Convert CSS function expression back to CSS string
*
* @param parsed - The CSS function expression
* @returns Original CSS function string
*/
export function cssFunctionToCSSValue(parsed) {
return parsed.value;
}
/**
* Clean up JSON string before parsing to handle common formatting issues
* @param jsonStr - The JSON string to clean up
* @returns Cleaned JSON string
*/
export function cleanupJSON(jsonStr) {
if (!jsonStr || typeof jsonStr !== 'string') {
return jsonStr;
}
let cleaned = jsonStr.trim();
// Remove any trailing commas before closing braces/brackets
cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
// Fix common quote issues - convert smart quotes to regular quotes
cleaned = cleaned.replace(/[""]/g, '"');
cleaned = cleaned.replace(/['']/g, "'");
// Remove any leading/trailing whitespace around the entire JSON
cleaned = cleaned.trim();
// Handle case where user might have copied from console.log output
// Remove any "Object " prefix that might appear
cleaned = cleaned.replace(/^Object\s+/, '');
// Handle case where there might be extra spaces around colons and commas
cleaned = cleaned.replace(/\s*:\s*/g, ':');
cleaned = cleaned.replace(/\s*,\s*/g, ',');
// Re-add proper spacing for readability
cleaned = cleaned.replace(/:/g, ': ');
cleaned = cleaned.replace(/,/g, ', ');
// Handle case where user might have pasted without outer braces
if (!cleaned.startsWith('{') && !cleaned.startsWith('[') && cleaned.includes(':')) {
cleaned = `{${cleaned}}`;
}
return cleaned;
}
/**
* Safe JSON parse with cleanup
* @param jsonStr - The JSON string to parse
* @returns Parsed JSON object or throws descriptive error
*/
export function safeJSONParse(jsonStr) {
const cleaned = cleanupJSON(jsonStr);
try {
return JSON.parse(cleaned);
}
catch (error) {
// Provide more helpful error messages
if (cleaned.includes('undefined')) {
throw new Error('JSON contains "undefined" which is not valid JSON. Use null instead.');
}
if (cleaned.includes("'") && !cleaned.includes('"')) {
throw new Error('JSON strings must use double quotes, not single quotes.');
}
if (!cleaned.startsWith('{') && !cleaned.startsWith('[')) {
throw new Error('JSON must start with { or [');
}
// Re-throw with original error message if we can't provide a better one
const originalError = error;
throw new Error(`Invalid JSON: ${originalError.message}`);
}
}