import crypto from 'crypto';
import path from 'path';
import { createRequire } from 'module';
import { logger } from '../core/runtime/AvenxLogger.js';
import { AvenxErrorCodes } from '../core/runtime/AvenxError.js';
import { StyleCompilerError } from './errors/index.js';
import { reportWarning } from './utils/warningReporter.js';
const require = createRequire(import.meta.url);
/**
* Strips CSS comments (/* ... *\/) from a CSS string, taking care not to touch comments within quoted strings.
* @param {string} css - The CSS string.
* @returns {string} The CSS string without comments.
*/
function stripCssComments(css) {
let result = '';
let inString = null; // null, '"', or "'"
let i = 0;
while (i < css.length) {
const char = css[i];
const nextChar = css[i + 1];
if (inString) {
result += char;
if (char === '\\') {
if (i + 1 < css.length) {
result += css[i + 1];
i += 2;
continue;
}
} else if (char === inString) {
inString = null;
}
i++;
} else {
if (char === '/' && nextChar === '*') {
// Start of comment - preserve newlines to maintain line number alignment
i += 2;
while (i < css.length) {
if (css[i] === '*' && css[i + 1] === '/') {
i += 2;
break;
}
if (css[i] === '\n') {
result += '\n';
} else {
result += ' ';
}
i++;
}
} else {
if (char === '"' || char === "'") {
inString = char;
}
result += char;
i++;
}
}
}
return result;
}
/**
* Scopes custom CSS properties (variables) defined within a component's stylesheet.
* Rewrites custom property declarations (e.g. --primary: red;) and usages (e.g. var(--primary))
* by appending the component's unique hash (e.g. --ax-<hashId>-primary).
* @param {string} cssContent - The CSS content (with comments stripped).
* @param {string} hash - The component scope hash (e.g. 'avenx-12345678').
* @returns {string} The CSS content with scoped custom properties.
*/
function scopeCustomProperties(cssContent, hash) {
if (!cssContent || !hash) return cssContent;
const hashId = hash.replace(/^avenx-/, '');
const localPropNames = new Set();
// 1. Scan CSS while ignoring text within string quotes ("..." or '...') to collect custom property declarations
let inString = null;
let i = 0;
while (i < cssContent.length) {
const char = cssContent[i];
if (inString) {
if (char === '\\') {
i += 2;
continue;
}
if (char === inString) {
inString = null;
}
i++;
continue;
}
if (char === '"' || char === "'") {
inString = char;
i++;
continue;
}
// Look for custom property declaration starting with '--'
if (char === '-' && cssContent[i + 1] === '-') {
const prevChar = i > 0 ? cssContent[i - 1] : ' ';
if (/[\s;{}]/.test(prevChar)) {
let j = i + 2;
let name = '';
while (j < cssContent.length && /[\w-]/.test(cssContent[j])) {
name += cssContent[j];
j++;
}
while (j < cssContent.length && /\s/.test(cssContent[j])) {
j++;
}
if (j < cssContent.length && cssContent[j] === ':') {
if (name.length > 0) {
localPropNames.add(name);
}
}
}
}
i++;
}
if (localPropNames.size === 0) return cssContent;
// 2. Sort names by length descending to prevent substring collision issues
const sortedPropNames = Array.from(localPropNames).sort((a, b) => b.length - a.length);
// 3. Replace property declarations and usages while preserving string literals
const chunks = [];
inString = null;
let lastIndex = 0;
i = 0;
while (i < cssContent.length) {
const char = cssContent[i];
if (inString) {
if (char === '\\') {
i += 2;
continue;
}
if (char === inString) {
chunks.push({ isString: true, text: cssContent.substring(lastIndex, i + 1) });
inString = null;
lastIndex = i + 1;
}
i++;
continue;
}
if (char === '"' || char === "'") {
if (i > lastIndex) {
chunks.push({ isString: false, text: cssContent.substring(lastIndex, i) });
}
inString = char;
lastIndex = i;
i++;
continue;
}
i++;
}
if (lastIndex < cssContent.length) {
chunks.push({ isString: inString !== null, text: cssContent.substring(lastIndex) });
}
for (const chunk of chunks) {
if (chunk.isString) continue;
for (const propName of sortedPropNames) {
const scopedVarName = `--ax-${hashId}-${propName}`;
const escapedPropName = propName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const declarationPattern = new RegExp(`(^|[\\s;{}])--${escapedPropName}(\\s*:)`, 'g');
chunk.text = chunk.text.replace(declarationPattern, `$1${scopedVarName}$2`);
const varPattern = new RegExp(`(var\\(\\s*)--${escapedPropName}(?=[\\s,)]|$)`, 'g');
chunk.text = chunk.text.replace(varPattern, `$1${scopedVarName}`);
}
}
return chunks.map((c) => c.text).join('');
}
/**
* Transforms deep CSS pseudo-selectors (:deep(...) / ::v-deep(...) / ::v-deep / :deep)
* into standard scoped selectors by removing the deep pseudo-selector wrappers/keywords.
* @param {string} selector - The CSS selector string.
* @returns {string} The transformed selector string.
*/
function transformDeepSelectors(selector) {
let result = selector;
// 1. Handle parenthesized :deep(...) and ::v-deep(...)
let matchIndex;
while ((matchIndex = result.search(/(?:::v-deep|:deep)\(/)) !== -1) {
const pseudoStart = matchIndex;
const openParenIdx = result.indexOf('(', pseudoStart);
let depth = 1;
let closeParenIdx = -1;
for (let i = openParenIdx + 1; i < result.length; i++) {
if (result[i] === '(') depth++;
else if (result[i] === ')') {
depth--;
if (depth === 0) {
closeParenIdx = i;
break;
}
}
}
if (closeParenIdx === -1) {
break;
}
let prefix = result.substring(0, pseudoStart);
const innerContent = result.substring(openParenIdx + 1, closeParenIdx).trim();
const suffix = result.substring(closeParenIdx + 1);
const needsSpaceBefore = prefix.length > 0 && !/\s$/.test(prefix);
if (/\s$/.test(prefix) && /^[\s]/.test(innerContent)) {
prefix = prefix.trimEnd();
}
result = prefix + (needsSpaceBefore ? ' ' : '') + innerContent + suffix;
}
// 2. Handle non-parenthesized ::v-deep and :deep (e.g. .parent ::v-deep .child)
result = result.replace(/\s*(?:::v-deep|:deep)(?=[\s>+~]|$)\s*/g, (match, offset, string) => {
const before = string.substring(0, offset);
const after = string.substring(offset + match.length);
if (!before || /^[\s>+~]/.test(after) || /[\s>+~]$/.test(before)) {
return '';
}
return ' ';
});
return result;
}
/**
* Applies a component hash to each selector in a selector list.
* Commas inside functions, attribute selectors, strings, and escapes are not
* selector delimiters and must be preserved.
* @param {string} selectorList - The CSS selector list.
* @param {string} hash - The component scope hash.
* @returns {string} The scoped selector list.
*/
function scopeSelectorList(selectorList, hash) {
const selectors = [];
let current = '';
let inString = null;
let parenthesisDepth = 0;
let bracketDepth = 0;
for (let i = 0; i < selectorList.length; i++) {
const char = selectorList[i];
if (char === '\\') {
current += char;
if (i + 1 < selectorList.length) {
current += selectorList[++i];
}
continue;
}
if (inString) {
current += char;
if (char === inString) inString = null;
continue;
}
if (char === '"' || char === "'") {
inString = char;
current += char;
} else if (char === '(') {
parenthesisDepth++;
current += char;
} else if (char === ')') {
parenthesisDepth = Math.max(0, parenthesisDepth - 1);
current += char;
} else if (char === '[') {
bracketDepth++;
current += char;
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1);
current += char;
} else if (char === ',' && parenthesisDepth === 0 && bracketDepth === 0) {
selectors.push(current);
current = '';
} else {
current += char;
}
}
selectors.push(current);
const hashClass = `.${hash}`;
return selectors
.map((selector) => selector.trim())
.filter(Boolean)
.map((selector) => {
let scoped;
if (selector.includes('&')) {
scoped = selector.replace(/&/g, hashClass);
} else if (selector.startsWith(hashClass) || selector.includes(hashClass)) {
scoped = selector;
} else {
scoped = `${hashClass}${selector}`;
}
return transformDeepSelectors(scoped);
})
.join(', ');
}
/**
* 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.
* @param {object} [options] - Configuration options.
* @param {object} [config] - Project configuration object.
*/
constructor(options = {}, config = null) {
this.options = options;
this.config = config || (options && options.config ? options.config : null);
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();
/**
* Raw global CSS rules metadata mapping.
* @type {object[]}
*/
this.rawGlobalCSSSourceInfo = [];
/**
* Scoped CSS rules.
* @type {string}
*/
this.scopedStyles = '';
this.#addedHashes = new Set();
/**
* Cache mapping original CSS file path to its content string.
* @type {Map<string, string>}
*/
this.sourcesCache = new Map();
/**
* List of scoped CSS rules to build the source map lines.
* @type {object[]}
*/
this.scopedRules = [];
/**
* Accumulated list of all generated CSS lines with their mapping metadata.
* @type {object[]}
*/
this.generatedLines = [];
}
/**
* Registers a source stylesheet content for source map generation.
* @param {string} filePath - Absolute file path of the style source.
* @param {string} content - CSS content of the source.
*/
registerSourceFile(filePath, content) {
this.sourcesCache.set(filePath, content);
}
/**
* 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.
* @param {string} [sourceFile] - Source file path.
* @param {number} [startLine] - Start line number in the source file.
*/
addGlobalCSS(css, sourceFile = '', startLine = 1) {
this.rawGlobalCSS.add(css);
this.rawGlobalCSSSourceInfo.push({ css, sourceFile, startLine });
}
/**
* Generates a base64 inline source map comment.
* @param {string} [distDir] - Output directory.
* @param {string} [cssFileName] - Output CSS filename.
* @returns {string} The base64 inline source map comment string.
*/
getInlineSourceMapComment(distDir = '', cssFileName = 'bundle.css') {
if (!this.generatedLines || this.generatedLines.length === 0) {
this.getGlobalStyles();
}
const map = this.getSourceMap(distDir, cssFileName);
const json = JSON.stringify(map);
const base64 = Buffer.from(json).toString('base64');
return `/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64} */`;
}
/**
* Retrieves the accumulated global styles.
* @param {object|boolean} [options] - Options or boolean indicating if dev inline source maps should be included.
* @param {Set<string>} [options.includeSources] - Stylesheet paths that reached
* the bundle. When given, styles from any other source file are left out.
* @returns {string} The complete CSS string for the application.
*/
getGlobalStyles(options = {}) {
this.generatedLines = [];
// Header comment
this.appendGeneratedLine('/* Generated by Avenx-JS */');
// Which stylesheets reached the bundle. Every component is compiled so that
// Atlas can describe the project as written, but a component the bundler
// shook out must not leave its CSS behind -- that would be dead weight the
// old pipeline did not ship, because it only ever parsed the components it
// kept.
const included = options && options.includeSources instanceof Set ? options.includeSources : null;
const wanted = (sourceFile) => !included || !sourceFile || included.has(sourceFile);
// 1. Global CSS rules
for (const css of this.rawGlobalCSS) {
const info = this.rawGlobalCSSSourceInfo.find((item) => item.css === css);
const sourceFile = info ? info.sourceFile : '';
const startLine = info ? info.startLine : 1;
if (!wanted(sourceFile)) {
continue;
}
const appliedCss = this.applyVariables(css);
const lines = appliedCss.split('\n');
lines.forEach((line, idx) => {
this.appendGeneratedLine(line, sourceFile, sourceFile ? startLine + idx : null);
});
}
// Divider comment
this.appendGeneratedLine('/* Scoped Styles */');
// 2. Scoped styles
for (const item of this.scopedRules) {
if (!wanted(item.sourceFile)) {
continue;
}
this.appendGeneratedLine(item.text, item.sourceFile, item.sourceLine);
}
let cssOutput = this.generatedLines.map((l) => l.text).join('\n');
const opts = typeof options === 'boolean' ? { dev: options } : options || {};
const isDevMode =
opts.dev === true ||
opts.inlineSourceMap === true ||
opts.sourceMap === 'inline' ||
(this.options &&
(this.options.dev === true || this.options.inlineSourceMap === true || this.options.sourceMap === 'inline')) ||
(this.config &&
(this.config.dev === true ||
(this.config.style && (this.config.style.dev === true || this.config.style.sourceMap === 'inline'))));
if (isDevMode) {
const distDir = opts.distDir || '';
const cssFileName = opts.cssFileName || 'bundle.css';
const inlineComment = this.getInlineSourceMapComment(distDir, cssFileName);
cssOutput += `\n${inlineComment}\n`;
}
return cssOutput;
}
/**
* Helper to append a generated CSS line.
* @param {string} text - Line contents.
* @param {string|null} [sourceFile] - Original stylesheet file path.
* @param {number|null} [sourceLine] - Original line number (1-based).
*/
appendGeneratedLine(text, sourceFile = null, sourceLine = null) {
this.generatedLines.push({
text,
sourceFile: sourceFile || null,
sourceLine: sourceLine !== null ? sourceLine : null,
});
}
/**
* Generates the Source Map v3 JSON object.
* @param {string} distDir - The absolute directory path of the output bundle.
* @param {string} cssFileName - The name of the CSS bundle file (e.g., 'bundle.css').
* @returns {object} The source map object.
*/
getSourceMap(distDir, cssFileName = 'bundle.css') {
const sourcesList = [];
const sourceToIndex = new Map();
this.generatedLines.forEach((line) => {
if (line.sourceFile && !sourceToIndex.has(line.sourceFile)) {
const relativePath = path.relative(distDir, line.sourceFile).replace(/\\/g, '/');
sourceToIndex.set(line.sourceFile, sourcesList.length);
sourcesList.push(relativePath);
}
});
const sourcesContent = sourcesList.map((relPath) => {
const absPath = Array.from(sourceToIndex.keys()).find(
(key) => path.relative(distDir, key).replace(/\\/g, '/') === relPath,
);
return this.sourcesCache.get(absPath) || '';
});
let mappings = '';
const state = {
prevGenCol: 0,
prevSourceIdx: 0,
prevSourceLine: 0,
prevSourceCol: 0,
};
this.generatedLines.forEach((line, idx) => {
if (idx > 0) {
mappings += ';';
}
if (line.sourceFile && line.sourceLine !== null) {
const sourceIdx = sourceToIndex.get(line.sourceFile);
const sourceLine0 = Math.max(0, line.sourceLine - 1);
state.prevGenCol = 0;
mappings += encodeMapping(0, sourceIdx, sourceLine0, 0, state);
}
});
return {
version: 3,
file: cssFileName,
sources: sourcesList,
sourcesContent: sourcesContent,
names: [],
mappings: mappings,
};
}
/**
* 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.
* @param {string} [desPath] - The original CSS file path.
* @returns {string} The modified HTML.
*/
process(html, desBlocks = {}, componentName = '', desPath = '') {
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 metadata = desBlocks._sourceMapInfo && desBlocks._sourceMapInfo[blockName];
const sourceFile = metadata ? metadata.sourceFile : desPath;
const startLine = metadata ? metadata.startLine : 1;
const hash = this.getHash(cssContent, componentName);
this.extractRules(this.applyVariables(cssContent), hash, sourceFile, startLine);
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] : null;
if (!cssContent) {
modifiedHtml = modifiedHtml.replace(fullMatch, '');
tagRegex.lastIndex = 0; // Restart because string changed
continue;
}
const metadata = desBlocks._sourceMapInfo && desBlocks._sourceMapInfo[blockName];
const sourceFile = metadata ? metadata.sourceFile : desPath;
const startLine = metadata ? metadata.startLine : 1;
const hash = this.getHash(cssContent, componentName);
this.extractRules(this.applyVariables(cssContent), hash, sourceFile, startLine);
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 = /(?<=^|\s)class="([^"]*)"|(?<=^|\s)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)
);
}
/**
* Processes CSS rules inside an @media block cleanly in a single AST pass,
* injecting component scope hash classes onto root element selectors without duplication.
* @param {string} selector - The @media query header (e.g. "@media (max-width: 600px)").
* @param {string} body - The CSS content inside the @media block.
* @param {number} originalLine - Source line number.
* @param {number} bodyStartLine - Source line number for body.
* @param {Function} scopeRulesFn - Function reference for scoping rules inside the media block.
* @returns {object[]} Array of scoped rule objects.
* @private
*/
_processMediaQueries(selector, body, originalLine, bodyStartLine, scopeRulesFn) {
const rules = [];
rules.push({ cssLine: `${selector} {`, sourceLine: originalLine });
const scopedBodyRules = scopeRulesFn(body, bodyStartLine);
scopedBodyRules.forEach((r) => {
rules.push({ cssLine: r.cssLine, sourceLine: r.sourceLine !== null ? r.sourceLine : originalLine });
});
rules.push({ cssLine: `}`, sourceLine: originalLine });
return rules;
}
/**
* 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.
* @param {string} [sourceFile] - Source file path.
* @param {number} [startLine] - Start line number in the source file.
* @private
*/
extractRules(cssContent, hash, sourceFile = '', startLine = 1) {
if (this.#addedHashes.has(hash)) return;
this.#addedHashes.add(hash);
const cleanCss = scopeCustomProperties(stripCssComments(cssContent), hash);
const scopeRules = (content, currentBaseLine) => {
const rules = [];
let current = '';
let depth = 0;
let inString = null;
let currentLineOffset = 0;
let ruleStartLineOffset = null;
const processPart = (part, originalLineOffset) => {
const rule = part.trim();
if (!rule) return;
const lineOffset = originalLineOffset !== null ? originalLineOffset : currentLineOffset;
const originalLine = currentBaseLine + lineOffset;
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')) {
const openBraceIdxInPart = part.indexOf('{');
const headerLines = part.substring(0, openBraceIdxInPart + 1).split('\n').length - 1;
const bodyStartLine = originalLine + headerLines;
const mediaRules = this._processMediaQueries(selector, body, originalLine, bodyStartLine, scopeRules);
mediaRules.forEach((r) => rules.push(r));
} else if (
selector.startsWith('@supports') ||
selector.startsWith('@document') ||
selector.startsWith('@container')
) {
const openBraceIdxInPart = part.indexOf('{');
const headerLines = part.substring(0, openBraceIdxInPart + 1).split('\n').length - 1;
const bodyStartLine = originalLine + headerLines;
// Recursively process the rules inside
const scopedBodyRules = scopeRules(body, bodyStartLine);
rules.push({ cssLine: `${selector} {`, sourceLine: originalLine });
scopedBodyRules.forEach((r) => {
rules.push({ cssLine: r.cssLine, sourceLine: r.sourceLine !== null ? r.sourceLine : originalLine });
});
rules.push({ cssLine: `}`, sourceLine: originalLine });
} else {
// Non-nesting at-rule (like @keyframes, @font-face) - keep body unchanged
rules.push({ cssLine: `${selector} {`, sourceLine: originalLine });
body.split('\n').forEach((line, idx) => {
rules.push({ cssLine: line, sourceLine: originalLine + idx });
});
rules.push({ cssLine: `}`, sourceLine: originalLine });
}
} else {
// Regular selector rule
selector = scopeSelectorList(selector, hash);
rules.push({ cssLine: `${selector} { ${body} }`, sourceLine: originalLine });
}
}
} 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);
if (props.length > 0) {
rules.push({ cssLine: `.${hash} { ${props.join('; ')}; }`, sourceLine: originalLine });
}
}
};
for (let i = 0; i < content.length; i++) {
const char = content[i];
if (char === '\n') {
currentLineOffset++;
}
if (inString) {
current += char;
if (char === '\\') {
if (i + 1 < content.length) {
current += content[i + 1];
if (content[i + 1] === '\n') currentLineOffset++;
i++;
}
} else if (char === inString) {
inString = null;
}
} else {
current += char;
if (ruleStartLineOffset === null && current.trim().length > 0) {
ruleStartLineOffset = currentLineOffset;
}
if (char === '"' || char === "'") {
inString = char;
} else if (char === '{') {
depth++;
} else if (char === '}') {
depth--;
}
if (depth === 0 && (char === ';' || char === '}')) {
processPart(current, ruleStartLineOffset);
current = '';
ruleStartLineOffset = null;
}
}
}
if (current.trim()) {
processPart(current, ruleStartLineOffset);
}
return rules;
};
const scoped = scopeRules(cleanCss, startLine);
scoped.forEach((r) => {
this.scopedStyles += r.cssLine + '\n';
this.scopedRules.push({
text: r.cssLine,
sourceFile: sourceFile,
sourceLine: r.sourceLine,
});
});
}
/**
* Requires a module dynamically.
* @param {string} name - The module name.
* @returns {any} The module or null.
* @private
*/
#requireModule(name) {
try {
return require(name);
} catch {
return null;
}
}
/**
* Preprocesses CSS content using the configured preprocessor.
* @param {string} cssContent - The raw CSS/SCSS content.
* @param {string} type - The preprocessor type.
* @returns {string} The compiled CSS.
*/
preprocessCss(cssContent, type) {
if (!type || type === 'none') return cssContent;
try {
if (type === 'sass' || type === 'scss') {
const sass = this.#requireModule('sass');
if (!sass) {
reportWarning(
AvenxErrorCodes.COMPILER_PREPROCESSOR_MISSING,
new StyleCompilerError(AvenxErrorCodes.COMPILER_PREPROCESSOR_MISSING, type),
this.config,
);
return cssContent;
}
const result = sass.compileString(cssContent, {
syntax: type === 'sass' ? 'indented' : 'scss',
});
return result.css;
}
if (type === 'postcss') {
const postcss = this.#requireModule('postcss');
if (!postcss) {
reportWarning(
AvenxErrorCodes.COMPILER_PREPROCESSOR_MISSING,
new StyleCompilerError(AvenxErrorCodes.COMPILER_PREPROCESSOR_MISSING, type),
this.config,
);
return cssContent;
}
const result = postcss([]).process(cssContent);
return result.css;
}
if (type === 'less') {
const less = this.#requireModule('less');
if (!less) {
reportWarning(
AvenxErrorCodes.COMPILER_PREPROCESSOR_MISSING,
new StyleCompilerError(AvenxErrorCodes.COMPILER_PREPROCESSOR_MISSING, type),
this.config,
);
return cssContent;
}
let output = cssContent;
less.render(cssContent, { syncImport: true }, (err, result) => {
if (err) throw err;
output = result.css;
});
return output;
}
} catch (err) {
logger.error(new StyleCompilerError(AvenxErrorCodes.COMPILER_PREPROCESSOR_FAILED, type, err.message).message);
return cssContent;
}
return cssContent;
}
/**
* Preprocesses an individual CSS block by wrapping it and compiling.
* @param {string} rawGlobalCss - The raw global CSS content.
* @param {string} blockBody - The raw CSS block body.
* @param {string} preprocessor - The preprocessor type.
* @returns {string} The preprocessed and filtered CSS block body.
*/
preprocessBlock(rawGlobalCss, blockBody, preprocessor) {
const placeholder = '__avenx_temp_class__';
const input = `${rawGlobalCss}\n\n.${placeholder} {\n${blockBody}\n}`;
const compiled = this.preprocessCss(input, preprocessor);
if (compiled === input) {
return blockBody;
}
return this.filterScopedRules(compiled, placeholder);
}
/**
* Filters compiled CSS rules to keep only those targeting the placeholder class.
* @param {string} compiledCss - The compiled CSS.
* @param {string} placeholder - The placeholder class name.
* @returns {string} The filtered rules with placeholder replaced by parent selector &.
*/
filterScopedRules(compiledCss, placeholder) {
const rules = [];
let current = '';
let depth = 0;
for (let i = 0; i < compiledCss.length; i++) {
const char = compiledCss[i];
current += char;
if (char === '{') {
depth++;
} else if (char === '}') {
depth--;
if (depth === 0) {
rules.push(current.trim());
current = '';
}
}
}
return rules
.filter((rule) => rule.includes(placeholder))
.map((rule) => rule.replaceAll(`.${placeholder}`, '&'))
.join('\n');
}
}
const VLQ_BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/**
* Encodes a single integer into Base64 VLQ.
* @param {number} value
* @returns {string}
*/
export function encodeVLQ(value) {
let vlq = value < 0 ? (-value << 1) | 1 : value << 1;
let encoded = '';
do {
let digit = vlq & 31;
vlq >>>= 5;
if (vlq > 0) {
digit |= 32;
}
encoded += VLQ_BASE64_CHARS[digit];
} while (vlq > 0);
return encoded;
}
/**
* Encodes a 4-tuple change using previous encoder state.
* @param {number} genCol
* @param {number} sourceIdx
* @param {number} sourceLine
* @param {number} sourceCol
* @param {object} state
* @returns {string}
*/
export function encodeMapping(genCol, sourceIdx, sourceLine, sourceCol, state) {
const dGenCol = genCol - state.prevGenCol;
const dSourceIdx = sourceIdx - state.prevSourceIdx;
const dSourceLine = sourceLine - state.prevSourceLine;
const dSourceCol = sourceCol - state.prevSourceCol;
state.prevGenCol = genCol;
state.prevSourceIdx = sourceIdx;
state.prevSourceLine = sourceLine;
state.prevSourceCol = sourceCol;
return encodeVLQ(dGenCol) + encodeVLQ(dSourceIdx) + encodeVLQ(dSourceLine) + encodeVLQ(dSourceCol);
}
export default StyleProcessor;