UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

221 lines (184 loc) 6.1 kB
/** * @fileoverview CSS Scoping System for OrdoJS Framework * Handles scoping CSS selectors to prevent style conflicts between components */ import { type CSSRuleNode, type StyleBlockNode } from '../types/index.js'; /** * CSS Scoping options */ export interface CSSScopingOptions { /** * Prefix to use for scoped selectors */ scopePrefix?: string; /** * Whether to use data attributes for scoping (true) or class names (false) */ useDataAttributes?: boolean; /** * Whether to preserve original selectors in addition to scoped ones */ preserveOriginalSelectors?: boolean; } /** * Default CSS Scoping options */ const DEFAULT_CSS_SCOPING_OPTIONS: CSSScopingOptions = { scopePrefix: 'ordojs', useDataAttributes: true, preserveOriginalSelectors: false }; /** * CSS Scoper for OrdoJS components */ export class OrdoJSCSSScoper { private options: CSSScopingOptions; constructor(options: Partial<CSSScopingOptions> = {}) { this.options = { ...DEFAULT_CSS_SCOPING_OPTIONS, ...options }; } /** * Scope CSS rules to a specific component */ scopeStyles(styleBlock: StyleBlockNode, componentName: string): StyleBlockNode { // If the style block is not scoped, return it as is if (!styleBlock.scoped) { return styleBlock; } // Generate a unique scope identifier for the component const scopeId = this.generateScopeId(componentName); // Scope each rule const scopedRules = styleBlock.rules.map(rule => this.scopeRule(rule, scopeId)); return { ...styleBlock, rules: scopedRules }; } /** * Scope a single CSS rule */ private scopeRule(rule: CSSRuleNode, scopeId: string): CSSRuleNode { const scopedSelector = this.scopeSelector(rule.selector, scopeId); return { ...rule, selector: scopedSelector }; } /** * Scope a CSS selector */ private scopeSelector(selector: string, scopeId: string): string { // Split complex selectors (comma-separated) const selectors = selector.split(',').map(s => s.trim()); // Scope each individual selector const scopedSelectors = selectors.map(s => this.scopeSingleSelector(s, scopeId)); // If preserving original selectors, include both versions if (this.options.preserveOriginalSelectors) { return [...selectors, ...scopedSelectors].join(', '); } return scopedSelectors.join(', '); } /** * Scope a single CSS selector (without commas) */ private scopeSingleSelector(selector: string, scopeId: string): string { // Handle special selectors if (selector === ':root' || selector === 'html' || selector === 'body') { return selector; } // Handle complex selectors with combinators const combinatorRegex = /(\s*[>+~]\s*)/; const parts = selector.split(combinatorRegex); // If there are combinators, scope each part separately if (parts.length > 1) { const scopedParts = parts.map((part, index) => { // Skip combinator parts (odd indices) if (index % 2 === 1) { return part; } // Scope the selector part return this.scopeSelectorPart(part.trim(), scopeId); }); return scopedParts.join(''); } // Simple selector without combinators return this.scopeSelectorPart(selector, scopeId); } /** * Scope a single selector part (no combinators) */ private scopeSelectorPart(selector: string, scopeId: string): string { // Handle pseudo-elements and pseudo-classes const hasPseudoElement = /::([a-z-]+)$/.test(selector); const hasPseudoClass = /:([a-z-]+)$/.test(selector); let baseSelector = selector; let pseudoSuffix = ''; // Extract pseudo-elements and pseudo-classes to append them after scoping if (hasPseudoElement) { const match = selector.match(/::([a-z-]+)$/); if (match) { baseSelector = selector.substring(0, selector.length - match[0].length); pseudoSuffix = match[0]; } } else if (hasPseudoClass) { const match = selector.match(/:([a-z-]+)$/); if (match) { baseSelector = selector.substring(0, selector.length - match[0].length); pseudoSuffix = match[0]; } } // Apply scoping attribute or class let scopedSelector: string; if (this.options.useDataAttributes) { scopedSelector = `${baseSelector}[data-${this.options.scopePrefix}-${scopeId}]${pseudoSuffix}`; } else { scopedSelector = `${baseSelector}.${this.options.scopePrefix}-${scopeId}${pseudoSuffix}`; } return scopedSelector; } /** * Generate a unique scope ID for a component */ private generateScopeId(componentName: string): string { // Create a hash from the component name for uniqueness const hash = this.hashString(componentName); return `${componentName.toLowerCase()}-${hash}`; } /** * Generate a simple hash from a string */ private hashString(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } // Convert to a positive hex string and take the first 8 characters return Math.abs(hash).toString(16).substring(0, 8); } /** * Generate the data attribute or class name for component markup */ generateScopeAttribute(componentName: string): string { const scopeId = this.generateScopeId(componentName); if (this.options.useDataAttributes) { return `data-${this.options.scopePrefix}-${scopeId}`; } else { return `class="${this.options.scopePrefix}-${scopeId}"`; } } /** * Convert a scoped StyleBlockNode back to CSS text */ generateScopedCSS(styleBlock: StyleBlockNode): string { return styleBlock.rules.map(rule => { const declarations = rule.declarations.map(decl => ` ${decl.property}: ${decl.value};` ).join('\n'); return `${rule.selector} {\n${declarations}\n}`; }).join('\n\n'); } }