Last active
June 27, 2025 03:20
-
-
Save schuhwerk/6358bd8b1c25cc82f8f62e36f524fcba to your computer and use it in GitHub Desktop.
Convert CSS to nested CSS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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