@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
221 lines (184 loc) • 6.1 kB
text/typescript
/**
* @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');
}
}