quarc/cli/processors/style-processor.ts

178 lines
5.8 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import * as sass from 'sass';
import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor';
import { ComponentIdRegistry } from './component-id-registry';
export class StyleProcessor extends BaseProcessor {
private componentIdRegistry = ComponentIdRegistry.getInstance();
get name(): string {
return 'style-processor';
}
async process(context: ProcessorContext): Promise<ProcessorResult> {
if (!context.source.includes('style')) {
return this.noChange(context.source);
}
const componentId = this.componentIdRegistry.getComponentId(context.filePath);
let source = context.source;
let modified = false;
const singleResult = await this.processStyleUrl(source, context, componentId);
if (singleResult.modified) {
source = singleResult.source;
modified = true;
}
const multiResult = await this.processStyleUrls(source, context, componentId);
if (multiResult.modified) {
source = multiResult.source;
modified = true;
}
return modified ? this.changed(source) : this.noChange(source);
}
private async processStyleUrl(
source: string,
context: ProcessorContext,
componentId: string,
): Promise<{ source: string; modified: boolean }> {
const regex = /styleUrl\s*[=:]\s*['"`]([^'"`]+)['"`]/g;
const matches = [...source.matchAll(regex)];
if (matches.length === 0) {
return { source, modified: false };
}
let result = source;
for (const match of matches.reverse()) {
const stylePath = match[1];
const fullPath = path.resolve(context.fileDir, stylePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Style not found: ${fullPath}`);
}
const content = await this.loadAndCompileStyle(fullPath);
const scoped = this.scopeStyles(content, componentId);
const escaped = this.escapeTemplate(scoped);
result = result.slice(0, match.index!) +
`style: \`${escaped}\`` +
result.slice(match.index! + match[0].length);
}
return { source: result, modified: true };
}
private async processStyleUrls(
source: string,
context: ProcessorContext,
componentId: string,
): Promise<{ source: string; modified: boolean }> {
const regex = /styleUrls\s*[=:]\s*\[([\s\S]*?)\]/g;
const matches = [...source.matchAll(regex)];
if (matches.length === 0) {
return { source, modified: false };
}
let result = source;
for (const match of matches.reverse()) {
const urlsContent = match[1];
const urlMatches = urlsContent.match(/['"`]([^'"`]+)['"`]/g);
if (!urlMatches) continue;
const styles: string[] = [];
for (const urlMatch of urlMatches) {
const stylePath = urlMatch.replace(/['"`]/g, '');
const fullPath = path.resolve(context.fileDir, stylePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Style not found: ${fullPath}`);
}
styles.push(await this.loadAndCompileStyle(fullPath));
}
const combined = styles.join('\n');
const scoped = this.scopeStyles(combined, componentId);
const escaped = this.escapeTemplate(scoped);
result = result.slice(0, match.index!) +
`style: \`${escaped}\`` +
result.slice(match.index! + match[0].length);
}
return { source: result, modified: true };
}
private async loadAndCompileStyle(filePath: string): Promise<string> {
const ext = path.extname(filePath).toLowerCase();
const content = await fs.promises.readFile(filePath, 'utf8');
if (ext === '.scss' || ext === '.sass') {
const result = sass.compileString(content, {
style: 'compressed',
sourceMap: false,
loadPaths: [path.dirname(filePath)],
});
return result.css;
}
return content;
}
private scopeStyles(css: string, componentId: string): string {
const attr = `[_ngcontent-${componentId}]`;
const hostAttr = `[_nghost-${componentId}]`;
let result = css;
result = result.replace(/:host\s*\(([^)]+)\)/g, (_, selector) => `${hostAttr}${selector}`);
result = result.replace(/:host/g, hostAttr);
result = result.replace(/([^{}]+)\{/g, (match, selector) => {
if (selector.includes(hostAttr)) return match;
const selectors = selector.split(',').map((s: string) => {
s = s.trim();
if (!s || s.startsWith('@') || s.startsWith('from') || s.startsWith('to')) {
return s;
}
return s.split(/\s+/)
.map((part: string) => {
if (['>', '+', '~'].includes(part) || part.includes(hostAttr)) {
return part;
}
const pseudoMatch = part.match(/^([^:]+)(::?.+)$/);
if (pseudoMatch) {
return `${pseudoMatch[1]}${attr}${pseudoMatch[2]}`;
}
return `${part}${attr}`;
})
.join(' ');
});
return selectors.join(', ') + ' {';
});
return result;
}
private escapeTemplate(content: string): string {
return content
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
}
}