UNPKG

@wix/css-property-parser

Version:

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

360 lines (359 loc) 12.4 kB
// CSS grid-template property evaluator // Handles CSS grid-template property values according to MDN specification // https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template import { isCssVariable, isGlobalKeyword } from '../utils/shared-utils.js'; import { parse as parseCSSVariable } from './css-variable.js'; import { parse as parseGridTemplateRows, toCSSValue as gridTemplateRowsToCSSValue } from './grid-template-rows.js'; import { parse as parseGridTemplateColumns, toCSSValue as gridTemplateColumnsToCSSValue } from './grid-template-columns.js'; /** * Parses a CSS grid-template property value according to MDN specification * * Supports: * - Keywords: none * - Row/Column syntax: <grid-template-rows> / <grid-template-columns> * - Areas syntax: [ <line-names>? <string> <track-size>? <line-names>? ]+ [ / <explicit-track-list> ]? * * @param value - The CSS value to parse * @returns Parsed GridTemplateValue 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 - return proper CSSVariable object if (isCssVariable(trimmed)) { return parseCSSVariable(trimmed); } // Global keywords if (isGlobalKeyword(trimmed)) { return { type: 'keyword', keyword: trimmed.toLowerCase() }; } // Property-specific keywords if (trimmed.toLowerCase() === 'none') { return { type: 'keyword', keyword: 'none' }; } // Check for areas syntax with strings (must not start with a number/length) if (trimmed.includes('"') || trimmed.includes("'")) { // Reject if it starts with a length value followed by quotes (invalid syntax) if (/^\d+\w*\s+["']/.test(trimmed)) { return null; } return parseAreasWithRowsColumns(trimmed); } // Check for rows/columns syntax with slash if (trimmed.includes('/')) { return parseRowsColumnsSlash(trimmed); } return null; } /** * Parses grid-template syntax: <grid-template-rows> / <grid-template-columns> */ function parseRowsColumnsSlash(value) { const slashIndex = value.indexOf('/'); if (slashIndex === -1) return null; const rowsStr = value.substring(0, slashIndex).trim(); const columnsStr = value.substring(slashIndex + 1).trim(); if (!rowsStr || !columnsStr) return null; const rows = parseGridTemplateRows(rowsStr); const columns = parseGridTemplateColumns(columnsStr); if (!rows || !columns) return null; return { type: 'grid-template-rows-columns', rows, columns }; } /** * Parses areas syntax: [ <line-names>? <string> <track-size>? <line-names>? ]+ [ / <explicit-track-list> ]? */ function parseAreasWithRowsColumns(value) { const slashIndex = value.lastIndexOf('/'); let areasRowsStr = value; let columnsStr = null; // Check if there's a columns part after the slash if (slashIndex !== -1) { const afterSlash = value.substring(slashIndex + 1).trim(); // Only consider it columns if it doesn't contain quotes (not part of a string) if (!afterSlash.includes('"') && !afterSlash.includes("'")) { areasRowsStr = value.substring(0, slashIndex).trim(); columnsStr = afterSlash; } } // Parse the areas and rows part const areasRowsResult = parseAreasWithRows(areasRowsStr); if (!areasRowsResult) return null; // Parse columns if present let columns = undefined; if (columnsStr) { const columnsResult = parseGridTemplateColumns(columnsStr); if (!columnsResult || columnsResult.type === 'keyword') return null; if (columnsResult.type === 'grid-template' && 'tracks' in columnsResult) { // Extract track sizes from the parsed result columns = columnsResult.tracks.filter(track => track.type !== 'line-name'); } } return { type: 'grid-template-with-areas', areas: areasRowsResult.areas, rows: areasRowsResult.rows, columns, lineNames: areasRowsResult.lineNames }; } /** * Parses areas with optional track sizes and line names */ function parseAreasWithRows(value) { const areas = []; const rows = []; const lineNames = {}; // Split into lines while respecting string boundaries const lines = splitAreaLines(value); for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; // Parse line: [line-names]? "area-string" track-size? [line-names]? const parseResult = parseAreaLine(trimmedLine); if (!parseResult) return null; areas.push(parseResult.area); if (parseResult.trackSize) { rows.push(parseResult.trackSize); } if (parseResult.lineNames) { Object.assign(lineNames, parseResult.lineNames); } } if (areas.length === 0) return null; // Validate that all areas have the same column count if (areas.length > 1) { const firstColumnCount = areas[0].split(/\s+/).length; for (let i = 1; i < areas.length; i++) { const columnCount = areas[i].split(/\s+/).length; if (columnCount !== firstColumnCount) { return null; // Inconsistent grid structure } } } return { areas, rows: rows.length > 0 ? rows : undefined, lineNames: Object.keys(lineNames).length > 0 ? lineNames : undefined }; } /** * Splits value into area lines while respecting string boundaries */ function splitAreaLines(value) { const lines = []; let current = ''; let inString = false; let stringChar = ''; for (let i = 0; i < value.length; i++) { const char = value[i]; if (!inString && (char === '"' || char === "'")) { inString = true; stringChar = char; current += char; } else if (inString && char === stringChar) { inString = false; stringChar = ''; current += char; // After closing quote, look for track size and line names until next string let j = i + 1; while (j < value.length) { const nextChar = value[j]; if (nextChar === '"' || nextChar === "'") { // Found next string, end current line break; } current += nextChar; j++; } lines.push(current.trim()); current = ''; i = j - 1; // j will be incremented by for loop } else if (!inString) { current += char; } else { current += char; } } if (current.trim()) { lines.push(current.trim()); } return lines.filter(line => line.length > 0); } /** * Parses a single area line */ function parseAreaLine(line) { // Find the area string (quoted) const areaMatch = line.match(/(['"])(.*?)\1/); if (!areaMatch) return null; const area = areaMatch[2]; const beforeArea = line.substring(0, areaMatch.index || 0).trim(); const afterArea = line.substring((areaMatch.index || 0) + areaMatch[0].length).trim(); // Parse line names and track size const lineNames = {}; let trackSize = undefined; // Parse before area (line names) if (beforeArea) { const beforeLineNames = parseLineNames(beforeArea); if (beforeLineNames) { Object.assign(lineNames, beforeLineNames); } } // Parse after area (track size and line names) if (afterArea) { const afterResult = parseTrackSizeAndLineNames(afterArea); if (afterResult) { if (afterResult.trackSize) { trackSize = afterResult.trackSize; } if (afterResult.lineNames) { Object.assign(lineNames, afterResult.lineNames); } } } return { area, trackSize, lineNames: Object.keys(lineNames).length > 0 ? lineNames : undefined }; } /** * Simple line names parser for [name1 name2] */ function parseLineNames(value) { const match = value.match(/\[(.*?)\]/); if (!match) return null; const names = match[1].trim().split(/\s+/).filter(name => name.length > 0); if (names.length === 0) return null; // Return as line names object const result = {}; names.forEach(name => { result[name] = []; }); return result; } /** * Parses track size and line names from after-area part */ function parseTrackSizeAndLineNames(value) { let trackSize = undefined; const lineNames = {}; // Extract line names first const lineNameMatches = value.match(/\[([^\]]+)\]/g); let remainingValue = value; if (lineNameMatches) { for (const match of lineNameMatches) { const names = match.slice(1, -1).trim().split(/\s+/).filter(name => name.length > 0); names.forEach(name => { lineNames[name] = []; }); remainingValue = remainingValue.replace(match, '').trim(); } } // Parse track size from remaining value if (remainingValue) { // Try to parse as track size - simplified for this implementation // In a full implementation, this would parse all track size types if (remainingValue === 'auto') { trackSize = { type: 'keyword', keyword: 'auto' }; } else if (remainingValue.endsWith('px') || remainingValue.endsWith('%') || remainingValue.endsWith('fr')) { // Simplified track size parsing const match = remainingValue.match(/^(\d+(?:\.\d+)?)(px|%|fr)$/); if (match) { const value = parseFloat(match[1]); const unit = match[2]; if (unit === 'fr') { trackSize = { type: 'flex', value, unit }; } else if (unit === '%') { trackSize = { type: 'percentage', value, unit: '%' }; } else { trackSize = { type: 'length', value, unit }; } } } } return { trackSize, lineNames: Object.keys(lineNames).length > 0 ? lineNames : undefined }; } /** * Converts a parsed GridTemplateValue back to CSS string representation */ export function toCSSValue(parsed) { if (!parsed) return null; if (parsed.type === 'keyword') { return parsed.keyword; } if (parsed.type === 'grid-template-rows-columns') { const rowsStr = gridTemplateRowsToCSSValue(parsed.rows); const columnsStr = gridTemplateColumnsToCSSValue(parsed.columns); if (!rowsStr || !columnsStr) return null; return `${rowsStr} / ${columnsStr}`; } if (parsed.type === 'grid-template-with-areas') { let result = ''; // Build areas with optional track sizes for (let i = 0; i < parsed.areas.length; i++) { const area = parsed.areas[i]; const trackSize = parsed.rows?.[i]; if (result) result += ' '; result += `"${area}"`; if (trackSize) { result += ` ${trackSizeToCSSValue(trackSize)}`; } } // Add columns if present if (parsed.columns && parsed.columns.length > 0) { const columnsStr = parsed.columns.map(track => trackSizeToCSSValue(track)).join(' '); result += ` / ${columnsStr}`; } return result; } return null; } /** * Helper to convert a track size to CSS string */ function trackSizeToCSSValue(trackSize) { if (trackSize.type === 'keyword') { return trackSize.keyword; } if (trackSize.type === 'length') { return `${trackSize.value}${trackSize.unit}`; } if (trackSize.type === 'percentage') { return `${trackSize.value}%`; } if (trackSize.type === 'flex') { return `${trackSize.value}fr`; } return 'auto'; }