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