UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

419 lines (351 loc) 9.55 kB
/** * @fileoverview CSS Parser for OrdoJS Framework * Parses CSS content into an AST structure for further processing */ import { type CSSDeclarationNode, type CSSRuleNode, type SourceLocation, type StyleBlockNode } from '../types/index.js'; /** * CSS Parser options */ export interface CSSParserOptions { /** * Whether to generate source maps */ generateSourceMaps?: boolean; /** * Whether to validate CSS syntax */ validateSyntax?: boolean; } /** * Default CSS Parser options */ const DEFAULT_CSS_PARSER_OPTIONS: CSSParserOptions = { generateSourceMaps: true, validateSyntax: true }; /** * CSS Parser for OrdoJS components */ export class OrdoJSCSSParser { private options: CSSParserOptions; private source: string = ''; private currentPosition: number = 0; private currentLine: number = 1; private currentColumn: number = 0; private errors: string[] = []; constructor(options: Partial<CSSParserOptions> = {}) { this.options = { ...DEFAULT_CSS_PARSER_OPTIONS, ...options }; } /** * Parse CSS content into a StyleBlockNode */ parse(source: string, scoped: boolean = true): StyleBlockNode { this.source = source; this.currentPosition = 0; this.currentLine = 1; this.currentColumn = 0; this.errors = []; const rules = this.parseRules(); if (this.options.validateSyntax && this.errors.length > 0) { throw new Error(`CSS parsing errors: ${this.errors.join(', ')}`); } return { type: 'StyleBlock', rules, scoped, range: this.createSourceRange(0, this.currentPosition) }; } /** * Parse CSS rules */ private parseRules(): CSSRuleNode[] { const rules: CSSRuleNode[] = []; this.skipWhitespace(); while (this.currentPosition < this.source.length) { const rule = this.parseRule(); if (rule) { rules.push(rule); } this.skipWhitespace(); } return rules; } /** * Parse a single CSS rule */ private parseRule(): CSSRuleNode | null { const startPos = this.currentPosition; // Parse selector const selector = this.parseSelector(); if (!selector) { return null; } this.skipWhitespace(); // Expect opening brace if (!this.consume('{')) { this.errors.push(`Expected '{' after selector '${selector}' at line ${this.currentLine}, column ${this.currentColumn}`); this.recoverToNextRule(); return null; } this.skipWhitespace(); // Parse declarations const declarations = this.parseDeclarations(); // Expect closing brace if (!this.consume('}')) { this.errors.push(`Expected '}' after declarations for selector '${selector}' at line ${this.currentLine}, column ${this.currentColumn}`); this.recoverToNextRule(); return null; } return { type: 'CSSRule', selector, declarations, range: this.createSourceRange(startPos, this.currentPosition) }; } /** * Parse a CSS selector */ private parseSelector(): string | null { const startPos = this.currentPosition; // Skip any leading whitespace this.skipWhitespace(); // Read until we find an opening brace or end of input let selector = ''; while ( this.currentPosition < this.source.length && this.source[this.currentPosition] !== '{' && this.source[this.currentPosition] !== '}' ) { selector += this.source[this.currentPosition]; this.advance(); } selector = selector.trim(); if (!selector) { this.errors.push(`Empty selector at line ${this.currentLine}, column ${this.currentColumn}`); return null; } return selector; } /** * Parse CSS declarations */ private parseDeclarations(): CSSDeclarationNode[] { const declarations: CSSDeclarationNode[] = []; this.skipWhitespace(); while ( this.currentPosition < this.source.length && this.source[this.currentPosition] !== '}' ) { const declaration = this.parseDeclaration(); if (declaration) { declarations.push(declaration); } // Skip semicolon and whitespace this.consume(';'); this.skipWhitespace(); } return declarations; } /** * Parse a single CSS declaration */ private parseDeclaration(): CSSDeclarationNode | null { const startPos = this.currentPosition; // Parse property const property = this.parseIdentifier(); if (!property) { this.recoverToNextDeclaration(); return null; } this.skipWhitespace(); // Expect colon if (!this.consume(':')) { this.errors.push(`Expected ':' after property '${property}' at line ${this.currentLine}, column ${this.currentColumn}`); this.recoverToNextDeclaration(); return null; } this.skipWhitespace(); // Parse value const value = this.parseValue(); if (!value) { this.recoverToNextDeclaration(); return null; } return { type: 'CSSDeclaration', property, value, important: false, range: this.createSourceRange(startPos, this.currentPosition) }; } /** * Parse a CSS property name */ private parseIdentifier(): string | null { const startPos = this.currentPosition; // CSS identifiers can start with a letter, underscore, or hyphen if ( this.currentPosition < this.source.length && !this.isIdentifierStart(this.source[this.currentPosition]) ) { this.errors.push(`Invalid identifier start at line ${this.currentLine}, column ${this.currentColumn}`); return null; } let identifier = ''; while ( this.currentPosition < this.source.length && this.isIdentifierPart(this.source[this.currentPosition]) ) { identifier += this.source[this.currentPosition]; this.advance(); } if (!identifier) { this.errors.push(`Empty identifier at line ${this.currentLine}, column ${this.currentColumn}`); return null; } return identifier; } /** * Parse a CSS property value */ private parseValue(): string | null { let value = ''; let parenDepth = 0; while (this.currentPosition < this.source.length) { const char = this.source[this.currentPosition]; // Handle nested parentheses in values like url(), calc(), etc. if (char === '(') { parenDepth++; } else if (char === ')') { parenDepth--; } // End of value when we hit a semicolon or closing brace (if not in parentheses) if ((char === ';' || char === '}') && parenDepth <= 0) { break; } value += char; this.advance(); } value = value.trim(); if (!value) { this.errors.push(`Empty value at line ${this.currentLine}, column ${this.currentColumn}`); return null; } return value; } /** * Check if a character is valid as the start of an identifier */ private isIdentifierStart(char: string): boolean { return /[a-zA-Z_\-]/.test(char); } /** * Check if a character is valid as part of an identifier */ private isIdentifierPart(char: string): boolean { return /[a-zA-Z0-9_\-]/.test(char); } /** * Advance the current position */ private advance(): void { if (this.source[this.currentPosition] === '\n') { this.currentLine++; this.currentColumn = 0; } else { this.currentColumn++; } this.currentPosition++; } /** * Skip whitespace characters */ private skipWhitespace(): void { while ( this.currentPosition < this.source.length && /\s/.test(this.source[this.currentPosition]) ) { this.advance(); } } /** * Consume an expected character */ private consume(expected: string): boolean { if ( this.currentPosition < this.source.length && this.source[this.currentPosition] === expected ) { this.advance(); return true; } return false; } /** * Recover from an error by advancing to the next rule */ private recoverToNextRule(): void { while ( this.currentPosition < this.source.length && this.source[this.currentPosition] !== '}' ) { this.advance(); } // Skip the closing brace if found if (this.currentPosition < this.source.length) { this.advance(); } } /** * Recover from an error by advancing to the next declaration */ private recoverToNextDeclaration(): void { while ( this.currentPosition < this.source.length && this.source[this.currentPosition] !== ';' && this.source[this.currentPosition] !== '}' ) { this.advance(); } // Skip the semicolon if found if (this.currentPosition < this.source.length && this.source[this.currentPosition] === ';') { this.advance(); } } /** * Create a source location object */ private createSourceLocation(start: number, end: number): SourceLocation { return { start, end, line: this.currentLine, column: this.currentColumn }; } /** * Create a source range object */ private createSourceRange(start: number, end: number): import('../types/index.js').SourceRange { return { start: { line: this.currentLine, column: this.currentColumn, offset: start }, end: { line: this.currentLine, column: this.currentColumn, offset: end } }; } }