const crypto = require('crypto');
/**
* StyleProcessor is responsible for handling all CSS-related logic during the build process.
* This includes managing global CSS variables, scoping component-specific styles using hashes,
* and extracting CSS rules into a global stylesheet.
*/
class StyleProcessor {
/**
* Tracks which hashes have already been added to globalStyles to prevent duplicates.
* @type {Set<string>}
* @private
*/
#addedHashes = new Set();
/**
* Creates an instance of StyleProcessor.
*/
constructor() {
this.reset();
}
/**
* Resets the processor state, clearing all accumulated styles and variables.
*/
reset() {
/**
* The accumulated global stylesheet content.
* @type {string}
*/
this.globalStyles = "";
/**
* A map of CSS variable names to their values.
* @type {Object<string, string>}
*/
this.cssVariables = {};
/**
* Raw global CSS rules added via addGlobalCSS.
* @type {Set<string>}
*/
this.rawGlobalCSS = new Set();
/**
* Scoped CSS rules.
* @type {string}
*/
this.scopedStyles = "";
this.#addedHashes = new Set();
}
/**
* Adds a global CSS variable to the processor.
* @param {string} name - The name of the variable (without the @ prefix).
* @param {string} value - The value of the variable.
*/
addVariable(name, value) {
this.cssVariables[name] = value;
}
/**
* Adds raw global CSS rules to the stylesheet.
* @param {string} css - The raw CSS string.
*/
addGlobalCSS(css) {
this.rawGlobalCSS.add(css);
}
/**
* Retrieves the accumulated global styles.
* @returns {string} The complete CSS string for the application.
*/
getGlobalStyles() {
let output = "/* Generated by Avenx-JS */\n";
// 1. Global CSS rules
for (const css of this.rawGlobalCSS) {
output += this.applyVariables(css) + "\n";
}
// 2. Scoped styles
output += "\n/* Scoped Styles */\n" + this.scopedStyles;
return output;
}
/**
* Processes the CSS within an HTML template. It identifies @css attributes,
* scopes the rules with a unique hash, replaces variables, and updates the HTML.
* @param {string} html - The HTML template.
* @param {Object} [desBlocks={}] - Pre-defined style blocks from a .component.css file.
* @param {string} [componentName=""] - The name of the component for hash generation.
* @returns {string} The modified HTML.
*/
process(html, desBlocks = {}, componentName = "") {
let modifiedHtml = html;
// 1. First, handle all @css attributes (new syntax)
// This replaces '@css name' with 'class="hash"' or merges with existing classes
modifiedHtml = modifiedHtml.replace(/<([^>]+)\s+@css\s+([\w-]+)([^>]*)>/g, (fullMatch, before, blockName, after) => {
const cssContent = desBlocks[blockName] || "";
if (!cssContent) return `<${before}${after}>`;
const hash = this.getHash(cssContent, componentName);
this.extractRules(this.applyVariables(cssContent), hash);
const tagWithClass = this.mergeClassIntoTag(before + after, hash);
return `<${tagWithClass}>`;
});
// 2. Then, handle all <@css /> tags (old/anonymous syntax)
const tagRegex = /<@css\s+([\w-]+)?\s*\/?>/g;
let match;
while ((match = tagRegex.exec(modifiedHtml)) !== null) {
const fullMatch = match[0];
const blockName = match[1];
const cssContent = blockName ? (desBlocks[blockName] || "") : "";
if (!cssContent) {
modifiedHtml = modifiedHtml.replace(fullMatch, '');
tagRegex.lastIndex = 0; // Restart because string changed
continue;
}
const hash = this.getHash(cssContent, componentName);
this.extractRules(this.applyVariables(cssContent), hash);
const matchIndex = match.index;
const beforeMatch = modifiedHtml.substring(0, matchIndex);
const lastTagStart = beforeMatch.lastIndexOf('<');
const lastTagEnd = beforeMatch.lastIndexOf('>');
if (lastTagStart !== -1 && lastTagStart > lastTagEnd) {
// Inside a tag: <div <@css ... /> >
const tagContent = modifiedHtml.substring(lastTagStart + 1, matchIndex);
const updatedTag = this.mergeClassIntoTag(tagContent, hash);
modifiedHtml = modifiedHtml.substring(0, lastTagStart + 1) +
updatedTag +
modifiedHtml.substring(matchIndex + fullMatch.length);
} else {
// Outside a tag: <div> <@css ... /> </div>
// Search for the previous tag to apply the class to
const prevTagRegex = /<([a-zA-Z0-9-]+)([^>]*)>$/;
const prevTagMatch = beforeMatch.trimEnd().match(prevTagRegex);
if (prevTagMatch) {
const tagStart = beforeMatch.lastIndexOf(prevTagMatch[0]);
const tagContent = prevTagMatch[1] + prevTagMatch[2];
const updatedTag = this.mergeClassIntoTag(tagContent, hash);
modifiedHtml = modifiedHtml.substring(0, tagStart + 1) +
updatedTag +
'>' +
modifiedHtml.substring(tagStart + prevTagMatch[0].length, matchIndex) +
modifiedHtml.substring(matchIndex + fullMatch.length);
} else {
modifiedHtml = modifiedHtml.replace(fullMatch, '');
}
}
tagRegex.lastIndex = 0; // Restart because string changed
}
return modifiedHtml;
}
/**
* Merges a CSS class hash into an existing tag string, handling existing class attributes.
* @param {string} tagContent - The content of the tag (e.g., "div id='foo'").
* @param {string} hash - The CSS class hash to merge.
* @returns {string} The updated tag content.
* @private
*/
mergeClassIntoTag(tagContent, hash) {
const classRegex = /class="([^"]*)"|class='([^']*)'/;
const match = tagContent.match(classRegex);
if (match) {
const isSingleQuote = match[2] !== undefined;
const existingClasses = isSingleQuote ? match[2] : match[1];
const quote = isSingleQuote ? "'" : '"';
if (existingClasses.includes(hash)) return tagContent;
const newClassAttr = `class=${quote}${hash} ${existingClasses}${quote}`;
return tagContent.replace(match[0], newClassAttr);
} else {
// Check if it's a self-closing tag or has other attributes
if (tagContent.trim().endsWith('/')) {
return tagContent.replace(/\s*\/$/, ` class="${hash}" /`);
}
return tagContent.trimEnd() + ` class="${hash}"`;
}
}
/**
* Replaces CSS variables (e.g., @primary) with their values.
* @param {string} cssContent - The CSS content to process.
* @returns {string} The CSS content with variables replaced.
*/
applyVariables(cssContent) {
let content = cssContent;
// Sort variables by length descending to prevent partial replacement (e.g., @primary vs @primary-hover)
const sortedVars = Object.entries(this.cssVariables).sort((a, b) => b[0].length - a[0].length);
for (const [varName, varValue] of sortedVars) {
// Use a negative lookahead to ensure we don't match a partial variable name
// that is actually followed by a hyphen or word characters.
const varRegex = new RegExp(`@${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'g');
content = content.replace(varRegex, varValue);
}
return content;
}
/**
* Generates a unique hash for a CSS block.
* @param {string} cssContent - The CSS content.
* @param {string} componentName - The name of the component.
* @returns {string} The generated hash.
*/
getHash(cssContent, componentName) {
return "avenx-" + crypto.createHash('md5').update(cssContent + componentName).digest('hex').substring(0, 8);
}
/**
* Extracts CSS rules from a content string, scopes them using the provided hash,
* and appends them to the global stylesheet.
* @param {string} cssContent - The CSS content to extract rules from.
* @param {string} hash - The hash to use for scoping.
* @private
*/
extractRules(cssContent, hash) {
if (this.#addedHashes.has(hash)) return;
this.#addedHashes.add(hash);
const scopeRules = (content, isNestedContext = false) => {
let baseRules = "";
let nestedRules = "";
let current = "";
let depth = 0;
const processPart = (part) => {
let rule = part.trim();
if (!rule) return;
if (rule.includes('{')) {
// It has a body block (nested rule or at-rule)
const openBraceIdx = rule.indexOf('{');
const closeBraceIdx = rule.lastIndexOf('}');
if (openBraceIdx !== -1 && closeBraceIdx !== -1) {
let selector = rule.substring(0, openBraceIdx).trim();
const body = rule.substring(openBraceIdx + 1, closeBraceIdx).trim();
if (selector.startsWith('@')) {
// Check if it's a nesting at-rule (like @media, @supports, @container, or @document)
if (selector.startsWith('@media') || selector.startsWith('@supports') || selector.startsWith('@document') || selector.startsWith('@container')) {
// Recursively process the rules inside
const scopedBody = scopeRules(body, true);
nestedRules += `${selector} {\n${scopedBody}}\n`;
} else {
// Non-nesting at-rule (like @keyframes, @font-face) - keep body unchanged
nestedRules += `${selector} {\n${body}\n}\n`;
}
} else {
// Regular selector rule
if (selector.includes('&')) {
selector = selector.replace(/&/g, `.${hash}`);
} else {
selector = `.${hash}${selector}`;
}
nestedRules += `${selector} { ${body} }\n`;
}
}
} else {
// It's one or more base properties (e.g. "color: red; margin: 0;")
const props = rule.split(';').map(p => p.trim()).filter(p => p.length > 0);
props.forEach(prop => {
baseRules += prop + "; ";
});
}
};
for (let i = 0; i < content.length; i++) {
const char = content[i];
current += char;
if (char === '{') depth++;
else if (char === '}') depth--;
if (depth === 0 && (char === ';' || char === '}')) {
processPart(current);
current = "";
}
}
if (current.trim()) processPart(current);
let result = "";
if (baseRules.trim()) {
result += `.${hash} { ${baseRules.trim()} }\n`;
}
if (nestedRules.trim()) {
result += nestedRules;
}
return result;
};
const scoped = scopeRules(cssContent, false);
this.scopedStyles += scoped;
}
}
module.exports = StyleProcessor;