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