UNPKG

@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
/** * 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}`); } }