UNPKG

@wix/css-property-parser

Version:

A comprehensive TypeScript library for parsing and serializing CSS property values with full MDN specification compliance

813 lines (812 loc) 27.1 kB
// Font shorthand property parser // Handles parsing of CSS font properties according to MDN specifications // https://developer.mozilla.org/en-US/docs/Web/CSS/font import { isCssVariable, isGlobalKeyword, tokenize, isKeywordInArray, getValidKeyword } from '../utils/shared-utils.js'; import { parse as parseCSSVariable, toCSSValue as cssVariableToCSSValue } from './css-variable.js'; import { parse as parseLength } from './length.js'; import { parse as parsePercentage } from './percentage.js'; import { parse as parseNumber } from './number.js'; // Import centralized types import { FONT_STYLE_KEYWORDS, FONT_VARIANT_KEYWORDS, FONT_WEIGHT_KEYWORDS, FONT_STRETCH_KEYWORDS, FONT_SIZE_KEYWORDS, SYSTEM_FONT_KEYWORDS } from '../types.js'; // Generic font families const GENERIC_FAMILIES = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong']; /** * Check if value is a font style keyword */ function isFontStyleKeyword(value) { return isKeywordInArray(value.toLowerCase(), FONT_STYLE_KEYWORDS); } /** * Check if value is a font variant keyword */ function isFontVariantKeyword(value) { return isKeywordInArray(value.toLowerCase(), FONT_VARIANT_KEYWORDS); } /** * Check if value is a font weight keyword */ function isFontWeightKeyword(value) { return isKeywordInArray(value.toLowerCase(), FONT_WEIGHT_KEYWORDS); } /** * Check if value is a font stretch keyword */ function isFontStretchKeyword(value) { return isKeywordInArray(value.toLowerCase(), FONT_STRETCH_KEYWORDS); } /** * Check if value is a system font keyword */ function isSystemFontKeyword(value) { return isKeywordInArray(value.toLowerCase(), SYSTEM_FONT_KEYWORDS); } /** * Check if value is a numeric font weight (1-1000) */ function isNumericFontWeight(value) { const num = parseInt(value, 10); return !isNaN(num) && num >= 1 && num <= 1000 && num.toString() === value; } /** * Parse font style value */ function parseFontStyle(value) { const lower = value.toLowerCase(); const keyword = getValidKeyword(lower, FONT_STYLE_KEYWORDS); if (keyword) { return { type: 'keyword', keyword }; } // Handle oblique with angle - NOT SUPPORTED in shorthand // According to MDN, oblique angles are only supported in individual font-style property // The font shorthand only supports: normal | italic | oblique (without angle) return null; } /** * Parse font variant value */ function parseFontVariant(value) { const lower = value.toLowerCase(); const keyword = getValidKeyword(lower, FONT_VARIANT_KEYWORDS); if (keyword) { return { type: 'keyword', keyword }; } return null; } /** * Parse font weight value */ function parseFontWeight(value) { const lower = value.toLowerCase(); const keyword = getValidKeyword(lower, FONT_WEIGHT_KEYWORDS); if (keyword) { return { type: 'keyword', keyword }; } if (isNumericFontWeight(value)) { return { value: parseInt(value, 10) }; } return null; } /** * Parse font stretch value */ function parseFontStretch(value) { const lower = value.toLowerCase(); const keyword = getValidKeyword(lower, FONT_STRETCH_KEYWORDS); if (keyword) { return { type: 'keyword', keyword }; } // Try percentage (50% - 200%) const percentageResult = parsePercentage(value); if (percentageResult && 'value' in percentageResult && percentageResult.value >= 50 && percentageResult.value <= 200) { return percentageResult; } return null; } /** * Parse font size value */ function parseFontSize(value) { const lower = value.toLowerCase(); // Keywords first const keyword = getValidKeyword(lower, FONT_SIZE_KEYWORDS); if (keyword) { return { type: 'keyword', keyword }; } // Percentage const percentageResult = parsePercentage(value); if (percentageResult && 'value' in percentageResult && percentageResult.value > 0) { return percentageResult; } // Length const lengthResult = parseLength(value); if (lengthResult && (('value' in lengthResult && lengthResult.value > 0) || ('expression' in lengthResult))) { return lengthResult; } return null; } /** * Parse line height value */ function parseLineHeight(value) { const lower = value.toLowerCase(); if (lower === 'normal') { return { type: 'keyword', keyword: 'normal' }; } // Number (unitless) - now supports CSS variables const numberResult = parseNumber(value); if (numberResult && ('CSSvariable' in numberResult || ('value' in numberResult && numberResult.value >= 0))) { return numberResult; } // Percentage - now supports CSS variables const percentageResult = parsePercentage(value); if (percentageResult && ('CSSvariable' in percentageResult || ('value' in percentageResult && percentageResult.value >= 0))) { return percentageResult; } // Length - now supports CSS variables const lengthResult = parseLength(value); if (lengthResult && ('CSSvariable' in lengthResult || (('value' in lengthResult && lengthResult.value >= 0) || ('expression' in lengthResult)))) { return lengthResult; } return null; } /** * Parse font family value */ function parseFontFamily(value) { if (!value || value.trim() === '') { return null; } const families = []; const parts = value.split(','); for (const part of parts) { const trimmed = part.trim(); if (trimmed === '') continue; // Check for invalid tokens that suggest this isn't a font family // Reject if it contains CSS units (px, em, rem, %, etc.) if (/\d+(px|em|rem|pt|pc|in|cm|mm|ex|ch|vh|vw|vmin|vmax|%)(\s|$)/i.test(trimmed)) { return null; } // Handle quotes - preserve them but validate the content const family = trimmed; let unquotedFamily = family; if ((family.startsWith('"') && family.endsWith('"')) || (family.startsWith("'") && family.endsWith("'"))) { unquotedFamily = family.slice(1, -1); // Reject empty quoted strings if (unquotedFamily === '') { return null; } // Process Unicode escape sequences in quoted strings try { unquotedFamily = unquotedFamily.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => { return String.fromCharCode(parseInt(hex, 16)); }); } catch { // Invalid Unicode escape sequence return null; } // Reject quoted strings with invalid characters (newlines, tabs, etc.) if (/[\n\r\t]/.test(unquotedFamily)) { return null; } } // Validate font family names for invalid characters // Generic families are always valid const genericFamilies = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong']; if (!genericFamilies.includes(unquotedFamily.toLowerCase())) { // For non-generic families, specifically reject characters that are clearly invalid in font names if (/[!@#$%^&*+=|\\/:;<>?`~]/.test(unquotedFamily)) { return null; } } families.push(family); // Keep the original quoted form } return families.length > 0 ? { families } : null; } /** * Parse font shorthand syntax */ function parseShorthand(value) { const tokens = tokenize(value); if (tokens.length < 2) return null; // Need at least size and family let fontStyle; let fontVariant; let fontWeight; let fontStretch; let fontSize; let lineHeight; let i = 0; // Parse optional style, variant, weight, stretch (any order before size) // CSS spec allows these properties in any order: [font-style || font-variant || font-weight || font-stretch] while (i < tokens.length - 2) { // Leave at least 2 tokens for size and family const token = tokens[i]; let parsed = false; // Special check for the specific ambiguous pattern: "normal small-caps normal" // This is only ambiguous when we have exactly these conditions and no disambiguating properties if (token.toLowerCase() === 'normal') { const hasNonNormalVariant = fontVariant && 'keyword' in fontVariant && fontVariant.keyword !== 'normal'; // Only reject the specific ambiguous case: style=normal, variant=small-caps, no weight/stretch set // If we have font-stretch set, or font-style is non-normal, it's not the ambiguous pattern const isAmbiguousPattern = hasNonNormalVariant && (!fontStyle || ('keyword' in fontStyle && fontStyle.keyword === 'normal')) && !fontWeight && !fontStretch; // If stretch is set, it's not the basic ambiguous pattern if (isAmbiguousPattern) { return null; // The specific ambiguous "normal small-caps normal" pattern } } // Try each property type (in any order) - only set if not already set // Try font-style (if not already set) if (!parsed && !fontStyle) { const styleResult = parseFontStyle(token); if (styleResult) { fontStyle = styleResult; parsed = true; } } // Try font-variant (if not already set) if (!parsed && !fontVariant) { const variantResult = parseFontVariant(token); if (variantResult) { fontVariant = variantResult; parsed = true; } } // Try font-weight (if not already set) if (!parsed && !fontWeight) { const weightResult = parseFontWeight(token); if (weightResult) { fontWeight = weightResult; parsed = true; } } // Try font-stretch (if not already set) if (!parsed && !fontStretch) { const stretchResult = parseFontStretch(token); if (stretchResult) { fontStretch = stretchResult; parsed = true; } } // Check for duplicate/contradictory properties if (!parsed) { // General duplicate check - if this token could be a property we already have set if ((fontStyle && parseFontStyle(token)) || (fontVariant && parseFontVariant(token)) || (fontWeight && parseFontWeight(token)) || (fontStretch && parseFontStretch(token))) { return null; // Duplicate or contradictory property } break; // Not a font property, stop parsing optional properties } i++; } // Parse font-size[/line-height] if (i >= tokens.length - 1) return null; // Not enough tokens left const sizeToken = tokens[i]; // Check for size/line-height syntax - handle both "16px/1.2" and "16px / 1.2" if (sizeToken.includes('/')) { const [sizeStr, lineHeightStr] = sizeToken.split('/', 2); const sizeResult = parseFontSize(sizeStr); if (!sizeResult) return null; fontSize = sizeResult; const lineHeightResult = parseLineHeight(lineHeightStr); if (!lineHeightResult) return null; lineHeight = lineHeightResult; } else if (i + 2 < tokens.length && tokens[i + 1] === '/') { // Handle "16px / 1.2" format (slash as separate token with whitespace) const sizeResult = parseFontSize(sizeToken); if (!sizeResult) return null; fontSize = sizeResult; const lineHeightToken = tokens[i + 2]; const lineHeightResult = parseLineHeight(lineHeightToken); if (!lineHeightResult) return null; lineHeight = lineHeightResult; i += 2; // Skip the "/" and line-height tokens } else { const sizeResult = parseFontSize(sizeToken); if (!sizeResult) return null; fontSize = sizeResult; } i++; // Parse font-family (remaining tokens) if (i >= tokens.length) return null; const familyTokens = tokens.slice(i); const familyStr = familyTokens.join(' '); // Validate that family doesn't contain font property keywords // This catches cases like "16px bold serif" where "bold serif" would be treated as family const familyWords = familyStr.toLowerCase().split(/\s+/); for (const word of familyWords) { if (isFontStyleKeyword(word) || isFontWeightKeyword(word) || isFontVariantKeyword(word) || isFontStretchKeyword(word) || isNumericFontWeight(word)) { return null; // Font properties after size are invalid } } const familyResult = parseFontFamily(familyStr); if (!familyResult) return null; const fontFamily = { type: 'font-list', families: familyResult.families }; const result = { fontSize, fontFamily }; if (fontStyle) result.fontStyle = fontStyle; if (fontVariant) result.fontVariant = fontVariant; if (fontWeight) result.fontWeight = fontWeight; if (fontStretch) result.fontStretch = fontStretch; if (lineHeight) result.lineHeight = lineHeight; return result; } /** * Parse a CSS font property string */ export function parse(value) { if (!value || typeof value !== 'string') { return null; } const trimmed = value.trim(); if (trimmed === '') { return null; } // CSS variables - parse and return directly if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'keyword', keyword: trimmed.toLowerCase() }; } // System font keywords if (isSystemFontKeyword(trimmed)) { return { type: 'keyword', keyword: trimmed.toLowerCase() }; } // Try to parse as shorthand const shorthandResult = parseShorthand(trimmed); if (shorthandResult) { return shorthandResult; } return null; } /** * Convert FontValue back to CSS string */ export function toCSSValue(parsed) { if (!parsed) { return null; } // Handle CSS variables if ('CSSvariable' in parsed) { return cssVariableToCSSValue(parsed); } // Handle property keywords and system fonts if ('keyword' in parsed) { return parsed.keyword; } // Handle shorthand expansion const parts = []; // Optional properties in order if (parsed.fontStyle) { if ('keyword' in parsed.fontStyle) { parts.push(parsed.fontStyle.keyword); } else if ('angle' in parsed.fontStyle) { if ('value' in parsed.fontStyle.angle) { parts.push(`oblique ${parsed.fontStyle.angle.value}${parsed.fontStyle.unit}`); } } } if (parsed.fontVariant) { if ('CSSvariable' in parsed.fontVariant) { parts.push(cssVariableToCSSValue(parsed.fontVariant) || ''); } else { parts.push(parsed.fontVariant.keyword); } } if (parsed.fontWeight) { if ('CSSvariable' in parsed.fontWeight) { parts.push(cssVariableToCSSValue(parsed.fontWeight) || ''); } else if ('keyword' in parsed.fontWeight) { parts.push(parsed.fontWeight.keyword); } else { parts.push(parsed.fontWeight.value.toString()); } } if (parsed.fontStretch) { if ('keyword' in parsed.fontStretch) { parts.push(parsed.fontStretch.keyword); } else if ('value' in parsed.fontStretch) { parts.push(`${parsed.fontStretch.value}%`); } } // Required font-size let sizeStr = ''; if ('keyword' in parsed.fontSize) { sizeStr = parsed.fontSize.keyword; } else if ('value' in parsed.fontSize && 'unit' in parsed.fontSize) { // Length value sizeStr = `${parsed.fontSize.value}${parsed.fontSize.unit}`; } else if ('value' in parsed.fontSize) { // Percentage value sizeStr = `${parsed.fontSize.value}%`; } else if ('expression' in parsed.fontSize) { sizeStr = `${parsed.fontSize.function}(${parsed.fontSize.expression})`; } // Optional line-height with / if (parsed.lineHeight) { if ('keyword' in parsed.lineHeight) { sizeStr += `/${parsed.lineHeight.keyword}`; } else if ('value' in parsed.lineHeight && 'unit' in parsed.lineHeight) { // Length value sizeStr += `/${parsed.lineHeight.value}${parsed.lineHeight.unit}`; } else if ('value' in parsed.lineHeight) { // Number or percentage value sizeStr += `/${parsed.lineHeight.value}`; } else if ('expression' in parsed.lineHeight) { sizeStr += `/${parsed.lineHeight.function}(${parsed.lineHeight.expression})`; } } parts.push(sizeStr); // Required font-family if ('CSSvariable' in parsed.fontFamily) { parts.push(cssVariableToCSSValue(parsed.fontFamily) || ''); } else { const families = parsed.fontFamily.families.map(family => { // Check if already quoted if ((family.startsWith('"') && family.endsWith('"')) || (family.startsWith("'") && family.endsWith("'"))) { return family; // Already quoted, return as-is } // Quote families with spaces or special characters // But don't quote generic families if (isKeywordInArray(family.toLowerCase(), GENERIC_FAMILIES)) { return family; } if (family.includes(' ') || /[^a-zA-Z0-9_-]/.test(family)) { return `"${family}"`; } return family; }); parts.push(families.join(', ')); } return parts.join(' '); } // Font Family constituent property export const Family = { parse(value) { if (!value || typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed === '') return null; // CSS variables if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'global', value: trimmed.toLowerCase(), originalString: value }; } // Parse font family const result = parseFontFamily(trimmed); if (result) { return { type: 'font-list', value: result.families.join(', '), families: result.families, originalString: value }; } return null; }, isOfType(value) { return this.parse(value) !== null; }, evaluate(parsed) { return parsed !== null && parsed.type !== undefined; }, toCSSValue(parsed) { if (!parsed) return null; return String(parsed.value); } }; // Font Style constituent property export const Style = { parse(value) { if (!value || typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed === '') return null; // CSS variables if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'global', value: trimmed.toLowerCase(), originalString: value }; } // Parse font style const result = parseFontStyle(trimmed); if (result) { if ('keyword' in result) { return { type: 'keyword', value: result.keyword, originalString: value }; } } return null; }, isOfType(value) { return this.parse(value) !== null; }, evaluate(parsed) { return parsed !== null && parsed.type !== undefined; }, toCSSValue(parsed) { if (!parsed) return null; return String(parsed.value); } }; // Font Variant constituent property export const Variant = { parse(value) { if (!value || typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed === '') return null; // CSS variables if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'global', value: trimmed.toLowerCase(), originalString: value }; } // Parse font variant const result = parseFontVariant(trimmed); if (result) { if ('keyword' in result) { return { type: 'keyword', value: result.keyword, originalString: value }; } } return null; }, isOfType(value) { return this.parse(value) !== null; }, evaluate(parsed) { return parsed !== null && parsed.type !== undefined; }, toCSSValue(parsed) { if (!parsed) return null; return String(parsed.value); } }; // Font Stretch constituent property export const Stretch = { parse(value) { if (!value || typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed === '') return null; // CSS variables if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'global', value: trimmed.toLowerCase(), originalString: value }; } // Parse font stretch const result = parseFontStretch(trimmed); if (result) { if ('keyword' in result) { return { type: 'keyword', value: result.keyword, originalString: value }; } else if ('value' in result) { return { type: 'percentage', value: `${result.value}%`, originalString: value }; } } return null; }, isOfType(value) { return this.parse(value) !== null; }, evaluate(parsed) { return parsed !== null && parsed.type !== undefined; }, toCSSValue(parsed) { if (!parsed) return null; return String(parsed.value); } }; // Font Weight constituent property export const Weight = { parse(value) { if (!value || typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed === '') return null; // CSS variables if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'global', value: trimmed.toLowerCase(), originalString: value }; } // Parse font weight const result = parseFontWeight(trimmed); if (result) { if ('keyword' in result) { return { type: 'keyword', value: result.keyword, originalString: value }; } else if ('value' in result) { return { type: 'number', value: result.value.toString(), originalString: value }; } } return null; }, isOfType(value) { return this.parse(value) !== null; }, evaluate(parsed) { return parsed !== null && parsed.type !== undefined; }, toCSSValue(parsed) { if (!parsed) return null; return String(parsed.value); } }; // Font Size constituent property export const Size = { parse(value) { if (!value || typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed === '') return null; // CSS variables if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'global', value: trimmed.toLowerCase(), originalString: value }; } // Parse font size const result = parseFontSize(trimmed); if (result) { if ('keyword' in result) { return { type: 'keyword', value: result.keyword, originalString: value }; } else if ('value' in result && 'unit' in result) { // Length value return { type: 'length', value: `${result.value}${result.unit}`, originalString: value }; } else if ('value' in result) { // Percentage value return { type: 'percentage', value: `${result.value}%`, originalString: value }; } else if ('expression' in result) { return { type: 'expression', value: result.expression, originalString: value }; } } return null; }, isOfType(value) { return this.parse(value) !== null; }, evaluate(parsed) { return parsed !== null && parsed.type !== undefined; }, toCSSValue(parsed) { if (!parsed) return null; return String(parsed.value); } };