Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active June 27, 2025 03:20
Show Gist options
  • Select an option

  • Save schuhwerk/6358bd8b1c25cc82f8f62e36f524fcba to your computer and use it in GitHub Desktop.

Select an option

Save schuhwerk/6358bd8b1c25cc82f8f62e36f524fcba to your computer and use it in GitHub Desktop.
Convert CSS to nested CSS
/**
* CSS Nesting Tool (mostly "vibe-coded" with claude-sonnet-4)
*
* Install/Usage
* - Install Bun (https://bun.sh/) / You can also use npm/npx
* - bun install css-tree # Install css-tree dependency
* - bun run nest-css.js plain-input.css > nested-output.css
*/
// because css-tree might not have a default export, or is a CommonJS module.
import * as csstree from 'css-tree'; // Imports the css-tree library and assigns all exports to 'csstree'
/**
* Accesses the command-line argument. In Bun/Node.js, arguments start from index 2
* (index 0 is 'bun', index 1 is the script path).
*/
const filePath = process.argv[2];
if (!filePath) {
console.error("Usage: bun run main.js <path-to-css-file>");
process.exit(1);
}
/**
* Reads the content of the specified CSS file using Bun's file API.
* @returns {Promise<string>} The content of the file.
*/
async function readCssFile(path) {
try {
const file = Bun.file(path);
return await file.text();
} catch (error) {
console.error(`Error reading file ${path}:`, error);
process.exit(1);
}
}
/**
* Parses the input CSS string into an Abstract Syntax Tree (AST) using css-tree.
* @param {string} input The CSS string to parse.
* @returns {object} The parsed AST.
*/
function parseCss(input) {
// css-tree's parse function expects a context for the top-level parsing.
return csstree.parse(input, { context: 'stylesheet' });
}
/**
* Defines the root structure for nesting, containing rules, nested selectors, and at-rules.
* Each branch in the tree (including root) will have this structure.
*/
const root = {
rules: [], // Stores csstree 'Rule' nodes for declarations at the current level
nest: {}, // Stores nested selectors as keys, with their own 'rules', 'nest', and 'atRules' properties
atRules: [] // Stores at-rules (like @media queries) as objects, each containing its prelude and a nested structure
};
// Array to store CSS rules and at-rules that cannot be nested (e.g., @keyframes, @font-face)
const notNested = [];
/**
* Generates a string with a specified number of spaces for indentation.
* @param {number} [count=0] The number of spaces.
* @returns {string} A string of spaces.
*/
function spaces(count = 0) {
return "".padStart(count, " ");
}
/**
* Checks if child selectors can be nested under parent selectors.
* @param {string[]} parentSelectors Array of parent selector strings.
* @param {string[]} childSelectors Array of child selector strings.
* @returns {boolean} True if child can be nested under parent.
*/
function canBeNested(parentSelectors, childSelectors) {
// Check if all child selectors are extensions of parent selectors
for (const childSelector of childSelectors) {
let canNest = false;
for (const parentSelector of parentSelectors) {
// Check various nesting patterns:
// 1. Direct descendant: .parent .child
// 2. Class combination: .parent.child
// 3. Combinators: .parent > .child, .parent + .child, .parent ~ .child
if (childSelector.startsWith(parentSelector + ' ') ||
childSelector.startsWith(parentSelector + '>') ||
childSelector.startsWith(parentSelector + '+') ||
childSelector.startsWith(parentSelector + '~') ||
(childSelector.startsWith(parentSelector) &&
childSelector.length > parentSelector.length &&
(childSelector[parentSelector.length] === '.' ||
childSelector[parentSelector.length] === ':' ||
childSelector[parentSelector.length] === '[' ||
childSelector[parentSelector.length] === ' '))) {
canNest = true;
break;
}
}
if (!canNest) return false;
}
return true;
}
/**
* Creates a nested selector string using & syntax.
* @param {string[]} parentSelectors Array of parent selector strings.
* @param {string[]} childSelectors Array of child selector strings.
* @returns {string} The nested selector string.
*/
function createNestedSelector(parentSelectors, childSelectors) {
const nestedSelectors = new Set(); // Use Set to avoid duplicates
for (const childSelector of childSelectors) {
for (const parentSelector of parentSelectors) {
if (childSelector.startsWith(parentSelector + ' ')) {
// Remove parent part and add & prefix
const remaining = childSelector.substring(parentSelector.length + 1);
nestedSelectors.add('& ' + remaining);
} else if (childSelector.startsWith(parentSelector + '>')) {
const remaining = childSelector.substring(parentSelector.length + 1);
nestedSelectors.add('&>' + remaining);
} else if (childSelector.startsWith(parentSelector + '+')) {
const remaining = childSelector.substring(parentSelector.length + 1);
nestedSelectors.add('&+' + remaining);
} else if (childSelector.startsWith(parentSelector + '~')) {
const remaining = childSelector.substring(parentSelector.length + 1);
nestedSelectors.add('&~' + remaining);
} else if (childSelector.startsWith(parentSelector) && childSelector.length > parentSelector.length) {
// Handle cases like .tabs.small -> &.small
const remaining = childSelector.substring(parentSelector.length);
if (remaining.startsWith('.') || remaining.startsWith(':') || remaining.startsWith('[')) {
nestedSelectors.add('&' + remaining);
}
}
}
}
return Array.from(nestedSelectors).join(', ');
}
/**
* Recursively populates a given branch of the nesting tree with rules and at-rules.
* This function processes rules and decides where they fit in the nested structure,
* including handling nested media queries.
* @param {Array<object>} rulesCollection The collection of csstree nodes to process (e.g., stylesheet children or block children).
* @param {object} targetBranch The branch object to populate (has rules, nest, and atRules properties).
*/
function populateBranch(rulesCollection, targetBranch) {
// First pass: collect all selectors to identify potential nesting relationships
const rules = [];
for (const ruleNode of rulesCollection) {
if (ruleNode.type === 'Rule') {
let selectors = [];
if (ruleNode.prelude && ruleNode.prelude.type === 'SelectorList') {
for (const selectorNode of ruleNode.prelude.children) {
selectors.push(csstree.generate(selectorNode));
}
rules.push({ node: ruleNode, selectors });
} else {
notNested.push(ruleNode);
}
} else if (ruleNode.type === 'Atrule') {
// Handle At-rules like @media.
if (ruleNode.name === 'media' && ruleNode.block && ruleNode.block.type === 'Block') {
// For @media rules, create a new sub-root to contain its internal rules.
const mediaRoot = { rules: [], nest: {}, atRules: [] };
// Process children of the media query directly within this mediaRoot scope.
// This handles nesting *within* the media query block.
populateBranch(ruleNode.block.children, mediaRoot);
// Add the processed media query to the target branch's atRules list.
targetBranch.atRules.push({
type: 'Atrule',
name: 'media',
prelude: ruleNode.prelude, // Store the original prelude (e.g., '(max-width: 768px)')
content: mediaRoot // Store the processed nested content
});
} else {
// Other At-rules (like @keyframes, @font-face) are pushed to notNested as they don't support
// the kind of nesting this script is designed for.
notNested.push(ruleNode);
}
} else {
// Any other unexpected node types are also pushed to notNested.
notNested.push(ruleNode);
}
}
// For simple cases like "b, strong" with same declarations, keep them together
// First, check if any rules can be kept as-is without nesting
const processedRules = new Set();
for (const rule of rules) {
if (processedRules.has(rule)) continue;
// Check if this rule should not be nested at all
const shouldNotNest = rule.selectors.some(selector => {
// Don't nest attribute selectors with complex patterns
if (selector.includes('[') && selector.includes('*')) {
return true;
}
// Don't nest selectors with multiple attribute selectors
const attributeCount = (selector.match(/\[/g) || []).length;
if (attributeCount > 1) {
return true;
}
// Don't nest selectors with pseudo-class functions like :not(), :is(), :where(), etc.
if (selector.match(/:[a-z-]+\(/)) {
return true;
}
return false;
});
// Check if this is a complex selector list that doesn't follow simple nesting patterns
const hasComplexMixedSelectors = rule.selectors.length > 1 && rule.selectors.some(selector => {
// Check for selectors with different structural patterns that shouldn't be nested together
const hasDescendant = selector.includes(' ');
// Only consider it complex if it has both descendant and class combination patterns mixed
return hasDescendant;
}) && rule.selectors.some(selector => {
const hasClassCombination = selector.match(/\.\w+\.\w+/);
return hasClassCombination;
});
// Check if this rule has multiple simple selectors (no nesting needed)
const allSimpleSelectors = rule.selectors.every(selector => {
return !selector.includes(' ') && !selector.includes('>') &&
!selector.includes('+') && !selector.includes('~');
});
if (shouldNotNest || hasComplexMixedSelectors) {
// Keep the rule as-is by using the combined selector
const combinedSelector = rule.selectors.join(', ');
if (!targetBranch.nest[combinedSelector]) {
targetBranch.nest[combinedSelector] = {
rules: [],
nest: {},
atRules: []
};
}
targetBranch.nest[combinedSelector].rules.push(rule.node);
processedRules.add(rule);
continue;
}
// Handle simple selectors but check if they can be nested
if (allSimpleSelectors) {
// Check if any of the simple selectors can be nested (like .tabs.small under .tabs)
let canNestSimpleSelectors = false;
for (const selector of rule.selectors) {
// Check for class combinations like .tabs.small
if (selector.includes('.') && selector.split('.').length > 2) {
canNestSimpleSelectors = true;
break;
}
}
if (!canNestSimpleSelectors) {
// Keep the rule as-is by using the combined selector
const combinedSelector = rule.selectors.join(', ');
if (!targetBranch.nest[combinedSelector]) {
targetBranch.nest[combinedSelector] = {
rules: [],
nest: {},
atRules: []
};
}
targetBranch.nest[combinedSelector].rules.push(rule.node);
processedRules.add(rule);
continue;
}
}
// For complex selectors, proceed with nesting logic
// Group rules by base selector for nesting
const selectorGroups = new Map();
for (const selector of rule.selectors) {
// Find potential base selectors
let baseSelector;
// Check for class combinations like .tabs.small -> base should be .tabs
if (selector.includes('.') && !selector.includes(' ') && !selector.includes('>') && !selector.includes('+') && !selector.includes('~')) {
const parts = selector.split('.');
if (parts.length > 2) { // .tabs.small -> ['', 'tabs', 'small']
baseSelector = '.' + parts[1]; // .tabs
} else {
baseSelector = selector;
}
} else {
// Find the base selector (everything before the first space/combinator)
const spaceIndex = selector.search(/[\s>+~]/);
baseSelector = spaceIndex === -1 ? selector : selector.substring(0, spaceIndex);
}
if (!selectorGroups.has(baseSelector)) {
selectorGroups.set(baseSelector, []);
}
selectorGroups.get(baseSelector).push(rule);
}
// Process each group - sort by complexity (shorter selectors first to create proper hierarchy)
const sortedGroups = Array.from(selectorGroups.entries()).sort((a, b) => {
const aComplexity = a[1][0].selectors[0].split(/[\s>+~]/).length;
const bComplexity = b[1][0].selectors[0].split(/[\s>+~]/).length;
return aComplexity - bComplexity;
});
for (const [baseSelector, groupRules] of sortedGroups) {
// Remove duplicates
const uniqueRules = Array.from(new Set(groupRules));
if (uniqueRules.length === 0) continue;
// Create the parent selector group
if (!targetBranch.nest[baseSelector]) {
targetBranch.nest[baseSelector] = {
rules: [],
nest: {},
atRules: []
};
}
// Sort rules by selector complexity (simpler first)
const sortedRules = uniqueRules.sort((a, b) => {
const aComplexity = a.selectors[0].split(/[\s>+~]/).length;
const bComplexity = b.selectors[0].split(/[\s>+~]/).length;
return aComplexity - bComplexity;
});
// Process all rules for this base selector
for (const groupRule of sortedRules) {
if (processedRules.has(groupRule)) continue;
// Check if any selector exactly matches the base selector
const hasExactMatch = groupRule.selectors.some(sel => sel === baseSelector);
if (hasExactMatch) {
// This rule contains the exact base selector, add it to the parent
targetBranch.nest[baseSelector].rules.push(groupRule.node);
processedRules.add(groupRule);
} else {
// This rule extends the base selector, create nested structure
if (canBeNested([baseSelector], groupRule.selectors)) {
const nestedSelector = createNestedSelector([baseSelector], groupRule.selectors);
if (nestedSelector) {
const originalSelector = groupRule.selectors[0]; // Take first selector for analysis
// Check if this should be further nested under an existing nested selector
let wasNested = false;
// Look for existing nested selectors that this selector might belong under
// Sort existing selectors by length (longer first) to match most specific parent
const existingSelectors = Object.keys(targetBranch.nest[baseSelector].nest)
.sort((a, b) => b.length - a.length);
for (const existingSelector of existingSelectors) {
const existingBranch = targetBranch.nest[baseSelector].nest[existingSelector];
// Reconstruct the full selector for comparison
const existingFullSelector = baseSelector + existingSelector.replace('&', '');
if (originalSelector.startsWith(existingFullSelector)) {
const remainingPart = originalSelector.substring(existingFullSelector.length);
if (remainingPart.startsWith('>') || remainingPart.startsWith(' ')) {
const furtherNestedSelector = '&' + remainingPart;
if (!existingBranch.nest[furtherNestedSelector]) {
existingBranch.nest[furtherNestedSelector] = {
rules: [],
nest: {},
atRules: []
};
}
existingBranch.nest[furtherNestedSelector].rules.push(groupRule.node);
wasNested = true;
break;
}
}
}
if (!wasNested) {
// Simple nesting
if (!targetBranch.nest[baseSelector].nest[nestedSelector]) {
targetBranch.nest[baseSelector].nest[nestedSelector] = {
rules: [],
nest: {},
atRules: []
};
}
targetBranch.nest[baseSelector].nest[nestedSelector].rules.push(groupRule.node);
}
processedRules.add(groupRule);
}
}
}
}
}
}
// Handle any remaining unprocessed rules
for (const rule of rules) {
if (!processedRules.has(rule)) {
const combinedSelector = rule.selectors.join(', ');
if (!targetBranch.nest[combinedSelector]) {
targetBranch.nest[combinedSelector] = {
rules: [],
nest: {},
atRules: []
};
}
targetBranch.nest[combinedSelector].rules.push(rule.node);
}
}
}
/**
* Writes CSS declarations from a csstree Rule node to the global output string.
* This function is specifically for Rule nodes with a block of declarations.
* @param {object} ruleNode The csstree 'Rule' node containing declarations.
* @param {string} [pad=""] The indentation string.
*/
let output = ""; // Global variable to accumulate the generated CSS output
function writeDeclarations(ruleNode, pad = "") {
if (ruleNode.block && ruleNode.block.type === 'Block') {
for (const declaration of ruleNode.block.children) {
if (declaration.type === 'Declaration') {
// Check if the value has children and contains URL nodes that need special handling
let valueStr = '';
let hasUrl = false;
if (declaration.value.children) {
for (const child of declaration.value.children) {
if (child.type === 'Url') {
// Handle URL values specially to avoid css-tree's escaping
// Add quotes for data URLs or URLs with special characters
const needsQuotes = child.value.startsWith('data:') ||
child.value.includes(' ') ||
child.value.includes('"') ||
child.value.includes("'");
if (needsQuotes) {
valueStr += `url('${child.value}')`;
} else {
valueStr += `url(${child.value})`;
}
hasUrl = true;
} else {
// For non-URL values, generate normally
valueStr += csstree.generate(child);
}
}
}
// If no URL was found or no children, use the default generation
if (!hasUrl) {
valueStr = csstree.generate(declaration.value);
}
const important = declaration.important ? ' !important' : '';
output += `${pad}${declaration.property}: ${valueStr}${important};\n`;
}
}
}
}
/**
* Recursively walks the nested CSS structure and generates the output CSS.
* This function handles both selector-based nesting and @media query nesting.
* @param {object} node The current node in the nested structure (root or a branch).
* @param {number} [indentation=0] The current indentation level.
* @param {string} [parentSelector=""] The selector of the parent node.
*/
function walk(node, indentation = 0, parentSelector = "") {
const pad = spaces(indentation);
// Write declarations for rules directly associated with the current selector/at-rule context.
for (const item of node.rules) {
if (item.type === 'Rule') {
writeDeclarations(item, pad);
}
}
// Iterate over nested selectors (e.g., .button:hover, .button span)
for (let selector in node.nest) {
const branch = node.nest[selector];
let outputSelector = selector;
// For nested output, use the selector as-is (including & syntax)
// Only resolve the currentSelector for further nesting calculations
let currentSelector = selector;
if (parentSelector && selector.startsWith('&')) {
currentSelector = parentSelector + selector.slice(1);
} else if (parentSelector) {
currentSelector = parentSelector + " " + selector;
}
// Check if this branch has any content (rules, nested selectors, or at-rules)
const hasRules = branch.rules.length > 0;
const hasNestedSelectors = Object.keys(branch.nest).length > 0;
const hasAtRules = branch.atRules.length > 0;
// Only output the selector if it has content
if (hasRules || hasNestedSelectors || hasAtRules) {
output += "\n" + pad + outputSelector + " {\n";
walk(branch, indentation + 2, currentSelector);
output += pad + "}\n";
}
}
// Iterate over nested at-rules (e.g., @media queries).
for (const atRule of node.atRules) {
if (atRule.type === 'Atrule' && atRule.name === 'media') {
output += "\n" + pad + `@media ${csstree.generate(atRule.prelude)} {\n`;
walk(atRule.content, indentation + 2, "");
output += pad + "}\n";
}
}
}
/**
* Writes a complete CSS rule (selector and declarations) or an Atrule,
* or a standalone declaration. This is used for items in the `notNested` array.
* @param {object} node The csstree 'Rule', 'Atrule', or 'Declaration' node.
* @param {number} [indentation=0] The indentation level.
*/
function writeRule(node, indentation = 0) {
const outerPad = spaces(indentation);
const innerPad = spaces(indentation + 2);
if (node.type === 'Rule') {
let selectors = [];
if (node.prelude && node.prelude.type === 'SelectorList') {
for (const selectorNode of node.prelude.children) {
selectors.push(csstree.generate(selectorNode));
}
}
const selectorString = selectors.join(", ");
output += `\n${outerPad}${selectorString} {\n`;
writeDeclarations(node, innerPad);
output += `${outerPad}}\n`;
} else if (node.type === 'Atrule') {
output += `\n${outerPad}@${node.name} `;
if (node.prelude) {
output += `${csstree.generate(node.prelude)} `
}
output += `{\n`;
if (node.block && node.block.type === 'Block') {
for (const childNode of node.block.children) {
writeRule(childNode, indentation + 2);
}
}
output += `${outerPad}}\n`;
} else if (node.type === 'Declaration') {
let valueStr = csstree.generate(node.value);
const important = node.important ? ' !important' : '';
output += `${outerPad}${node.property}: ${valueStr}${important};\n`;
}
}
/**
* Main execution function.
*/
async function main() {
// Reset global variables for clean execution
output = "";
root.rules = [];
root.nest = {};
root.atRules = [];
notNested.length = 0;
const inputCss = await readCssFile(filePath);
const parsedAst = parseCss(inputCss);
populateBranch(parsedAst.children, root);
walk(root, 0, "");
for (const otherNode of notNested) {
writeRule(otherNode);
}
console.log(output);
}
// Execute the main function
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment