diff --git a/CHANGELOG.md b/CHANGELOG.md index 809445a..87c6049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## UNRELEASED + +### Adds +* Refactors the `AtRule` to handle Tailwind 4.x nesting of media queries + +### Fixed + +* Changes the wrapping of the `:where()` pseudoclass so that it doesn't set specificity of some elements to `0`, resulting in a broken cascade. + ## 2.0.1 (2025-08-06) ### Fixed diff --git a/index.js b/index.js index e722cd8..9097339 100644 --- a/index.js +++ b/index.js @@ -46,8 +46,8 @@ const plugin = (opts = {}) => { ...options }); const ruleProcessor = createRuleProcessor({ - unitConverter, - ...options + ...options, + unitConverter }); const selectorHelper = createSelectorHelper({ modifierAttr }); @@ -57,6 +57,11 @@ const plugin = (opts = {}) => { // Flag to track if container context like sticky has been added let hasAddedContainerContext = false; + const isSameMediaQuery = (mq1, mq2) => { + return mq1.params === mq2.params && + mq1.source?.start?.line === mq2.source?.start?.line; + }; + /** * Adds a container context with `position: relative` and `contain: layout` if required. * @@ -171,10 +176,17 @@ const plugin = (opts = {}) => { AtRule: { media(atRule, helpers) { + debugUtils.logMediaQuery(atRule, 'START'); + if (atRule[processed]) { + debugUtils.log('Skipping already processed media query', atRule); return; } + // Check if this media query is nested inside a rule + const isNested = atRule.parent?.type === 'rule'; + debugUtils.log(`Media query is ${isNested ? 'NESTED' : 'TOP-LEVEL'}`, atRule); + let hasNotSelector = false; atRule.walkRules(rule => { if (rule.selector.includes(conditionalNotSelector)) { @@ -183,19 +195,25 @@ const plugin = (opts = {}) => { }); if (hasNotSelector) { + debugUtils.log('Skipping - already has not selector', atRule); atRule[processed] = true; return; } const conditions = mediaProcessor.getMediaConditions(atRule); + debugUtils.log(`Extracted conditions: ${JSON.stringify(conditions)}`, atRule); + if (conditions.length > 0) { debugUtils.stats.mediaQueriesProcessed++; - // Create container version first const containerConditions = mediaProcessor.convertToContainerConditions(conditions); + debugUtils.log(`Container conditions: ${containerConditions}`, atRule); + if (containerConditions) { + debugUtils.log('Creating container query...', atRule); + const containerQuery = new helpers.AtRule({ name: 'container', params: containerConditions, @@ -203,59 +221,254 @@ const plugin = (opts = {}) => { from: helpers.result.opts.from }); - // Clone and process rules for container query - keep selectors clean - atRule.walkRules(rule => { - const containerRule = rule.clone({ - source: rule.source, - from: helpers.result.opts.from, - selector: selectorHelper.updateBodySelectors( - rule.selector, - [ containerBodySelector ] - ) - }); - - ruleProcessor.processDeclarations(containerRule, { - isContainer: true, - from: helpers.result.opts.from + // For nested media queries + if (isNested) { + debugUtils.log('Processing nested media query declarations...', atRule); + + atRule.each(node => { + if (node.type === 'decl') { + debugUtils.log(` Processing declaration: ${node.prop}: ${node.value}`, atRule); + + const containerDecl = node.clone({ + source: node.source, + from: helpers.result.opts.from + }); + + // Convert viewport units if needed + let value = containerDecl.value; + if (Object.keys(unitConverter.units) + .some(unit => value.includes(unit))) { + value = unitConverter.convertUnitsInExpression(value); + containerDecl.value = value; + debugUtils.log(` Converted value to: ${value}`, atRule); + } + + containerQuery.append(containerDecl); + } }); - containerRule.raws.before = '\n '; - containerRule.raws.after = '\n '; - containerRule.walkDecls(decl => { - decl.raws.before = '\n '; + debugUtils.log(` Total declarations in container query: ${containerQuery.nodes?.length || 0}`, atRule); + + const parentRule = atRule.parent; + + // Find the root nesting level + let rootParent = parentRule; + let isSingleLevel = true; + while (rootParent.parent && rootParent.parent.type === 'rule') { + rootParent = rootParent.parent; + isSingleLevel = false; + } + + // For single-level nesting (Tailwind case), use simple approach + if (isSingleLevel) { + // Add container query inside the parent rule, after the media query + atRule.after(containerQuery); + + const originalSelector = parentRule.selector; + + let conditionalRule = null; + let alreadyHasWrapper = false; + + let prevNode = parentRule.prev(); + const targetSelector = selectorHelper + .addTargetToSelectors( + originalSelector, + conditionalNotSelector + ); + + while (prevNode) { + if (prevNode.type === 'rule' && prevNode.selector === targetSelector) { + conditionalRule = prevNode; + alreadyHasWrapper = true; + debugUtils.log('Found existing conditional wrapper, reusing it', atRule); + break; + } + prevNode = prevNode.prev(); + } + + if (!alreadyHasWrapper) { + conditionalRule = new helpers.Rule({ + selector: targetSelector, + source: parentRule.source, + from: helpers.result.opts.from + }); + + parentRule.before(conditionalRule); + debugUtils.log('Created new conditional wrapper', atRule); + } + + const clonedMedia = atRule.clone(); + clonedMedia[processed] = true; + conditionalRule.append(clonedMedia); + atRule.remove(); + + debugUtils.log('Added conditional wrapper for nested media query', atRule); + + } else { + // Multi-level nesting - hoist to root with full structure + const rootSelector = rootParent.selector; + + // Check if wrapper exists at root level + let conditionalRule = null; + let alreadyHasWrapper = false; + + let prevNode = rootParent.prev(); + const targetSelector = selectorHelper + .addTargetToSelectors( + rootSelector, + conditionalNotSelector + ); + + while (prevNode) { + if (prevNode.type === 'rule' && prevNode.selector === targetSelector) { + conditionalRule = prevNode; + alreadyHasWrapper = true; + debugUtils.log('Found existing conditional wrapper, reusing it', atRule); + break; + } + prevNode = prevNode.prev(); + } + + if (!alreadyHasWrapper) { + // Create wrapper with full nested structure + conditionalRule = new helpers.Rule({ + selector: targetSelector, + source: rootParent.source, + from: helpers.result.opts.from + }); + + // Clone children of root parent (before container query is added) + rootParent.each(node => { + const clonedNode = node.clone(); + + // Remove media queries that are NOT the current one being processed + clonedNode.walkAtRules('media', (mediaRule) => { + // Keep the current media query we're processing, remove others + if (mediaRule !== atRule && !isSameMediaQuery(mediaRule, atRule)) { + mediaRule.remove(); + } else { + mediaRule[processed] = true; + } + }); + + // If this node itself is a media query + // and not the one we're processing, skip it + if (clonedNode.type === 'atrule' && clonedNode.name === 'media') { + if (!isSameMediaQuery(clonedNode, atRule)) { + return; // Skip appending + } + clonedNode[processed] = true; + } + + conditionalRule.append(clonedNode); + }); + + rootParent.before(conditionalRule); + debugUtils.log('Created new conditional wrapper at root level', atRule); + } else { + // Wrapper exists, add media query to matching nested location + const targetInWrapper = conditionalRule.first; + if (targetInWrapper && targetInWrapper.type === 'rule') { + // Build path from parentRule to rootParent + const pathSelectors = []; + let current = parentRule; + while (current !== rootParent) { + pathSelectors.unshift(current.selector); + current = current.parent; + } + + // Navigate to matching location in wrapper + let navNode = targetInWrapper; + for (const selector of pathSelectors) { + let found = false; + navNode.each(node => { + if (node.type === 'rule' && node.selector === selector) { + navNode = node; + found = true; + return false; + } + }); + if (!found) { + break; + } + } + + // Add cloned media to correct location + const clonedMedia = atRule.clone(); + clonedMedia[processed] = true; + navNode.append(clonedMedia); + } + } + + // Remove from original + atRule.remove(); + + // Now add container query to original (after cloning) + parentRule.append(containerQuery); + + debugUtils.log('Added conditional wrapper for nested media query', atRule); + } + + } else { + // Original logic for top-level media queries + atRule.walkRules(rule => { + const containerRule = rule.clone({ + source: rule.source, + from: helpers.result.opts.from, + selector: selectorHelper.updateBodySelectors( + rule.selector, + [ containerBodySelector ] + ) + }); + + ruleProcessor.processDeclarations(containerRule, { + isContainer: true, + from: helpers.result.opts.from + }); + + containerRule.raws.before = '\n '; + containerRule.raws.after = '\n '; + containerRule.walkDecls(decl => { + decl.raws.before = '\n '; + }); + + containerQuery.append(containerRule); }); - containerQuery.append(containerRule); - }); - - // Add container query - atRule.after(containerQuery); + // Add container query + atRule.after(containerQuery); + } } // Now handle viewport media query modifications // We want the original media query to get the not selector - atRule.walkRules(rule => { - // Skip if already modified with not selector - if (rule.selector.includes(conditionalNotSelector)) { - return; - } + if (!isNested) { + atRule.walkRules(rule => { + // Skip if already modified with not selector + if (rule.selector.includes(conditionalNotSelector)) { + return; + } - const viewportRule = rule.clone({ - source: rule.source, - from: helpers.result.opts.from - }); + const viewportRule = rule.clone({ + source: rule.source, + from: helpers.result.opts.from + }); - viewportRule.selector = selectorHelper.addTargetToSelectors( - rule.selector, - conditionalNotSelector - ); + viewportRule.selector = selectorHelper.addTargetToSelectors( + rule.selector, + conditionalNotSelector + ); - rule.replaceWith(viewportRule); - }); + rule.replaceWith(viewportRule); + }); + } + } else { + debugUtils.log('No conditions found - skipping', atRule); } // Only mark the atRule as processed after all transformations atRule[processed] = true; + debugUtils.logMediaQuery(atRule, 'END'); } }, diff --git a/src/utils/debug.js b/src/utils/debug.js index 0ac812e..534ecbd 100644 --- a/src/utils/debug.js +++ b/src/utils/debug.js @@ -63,9 +63,31 @@ const createDebugUtils = ({ debug, debugFilter }) => { Array.from(stats.sourceFiles).join('\n ')); }; + /** + * Logs detailed information about media query processing + */ + const logMediaQuery = (atRule, context = '') => { + if (!debug) { + return; + } + + const source = atRule.source?.input?.file || 'unknown source'; + if (debugFilter && !source.includes(debugFilter)) { + return; + } + + console.log(`\n[Media Query ${context}] (${source})`); + console.log(' Params:', atRule.params); + console.log(' Parent type:', atRule.parent?.type); + console.log(' Parent selector:', atRule.parent?.selector || 'N/A'); + console.log(' Is nested:', atRule.parent?.type === 'rule'); + console.log(' Content preview:', atRule.toString().substring(0, 200)); + }; + return { stats, log, + logMediaQuery, printSummary }; }; diff --git a/src/utils/selectorHelper.js b/src/utils/selectorHelper.js index 491ce8c..975c0fd 100644 --- a/src/utils/selectorHelper.js +++ b/src/utils/selectorHelper.js @@ -1,7 +1,12 @@ - const createSelectorHelper = ({ modifierAttr }) => { const bodyRegex = /^body|^html.*\s+body|^html.*\s*>\s*body/; const tagRegex = /^\.|^#|^\[|^:/; + + /** + * Strategic use of :where() - only wrap the added targeting attributes, + * not the entire selector. This preserves the original selector's specificity + * while adding minimal specificity for the targeting mechanism. + */ const wrapInWhere = (selector) => `:where(${selector})`; const addTargetToSelectors = ( @@ -13,6 +18,7 @@ const createSelectorHelper = ({ modifierAttr }) => { .reduce((acc, part) => { const trimmed = part.trim(); const isBodySelector = trimmed.match(bodyRegex); + if (!isBodySelector) { acc.push(`${wrapInWhere(target)} ${trimmed}`); } @@ -61,6 +67,7 @@ const createSelectorHelper = ({ modifierAttr }) => { selector = selector.replace(bodyRegex, ''); // Selector is a body without identifiers, we put style in the body directly + // Don't wrap here since this IS the body being replaced if (!selector) { return target; } @@ -70,6 +77,7 @@ const createSelectorHelper = ({ modifierAttr }) => { // in case the body has this identifier const noTagSelector = selector.match(tagRegex); if (noTagSelector) { + // For body-level selectors, wrap only if it's not a body replacement const targetSelector = isBodySelector ? target : wrapInWhere(target); return `${targetSelector}${selector}`; } diff --git a/test/index.js b/test/index.js index e1d2461..c1907d6 100644 --- a/test/index.js +++ b/test/index.js @@ -7,7 +7,7 @@ const opts = { modifierAttr: 'data-breakpoint-preview-mode' }; let currentFileName = ''; // Hook into Mocha's test context -beforeEach(function() { +beforeEach(function () { currentFileName = this.currentTest.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); }); @@ -551,6 +551,192 @@ body[data-breakpoint-preview-mode] { await run(plugin, input, output, opts); }); + + it('should convert nested media queries to container queries (Tailwind)', async () => { + const input = ` +.sm\\:text-lg { + @media (width >= 40rem) { + font-size: var(--text-lg); + line-height: 1.5; + } +} +`; + + const output = ` +:where(body:not([data-breakpoint-preview-mode])) .sm\\:text-lg, + :where(body:not([data-breakpoint-preview-mode])).sm\\:text-lg { + @media (width >= 40rem) { + font-size: var(--text-lg); + line-height: 1.5; + } +} +.sm\\:text-lg { + @container (min-width: 40rem) { + font-size: var(--text-lg); + line-height: 1.5; + } +} +`; + + await run(plugin, input, output, opts); + }); + + it('should handle multiple nested breakpoints', async () => { + const input = ` +.responsive { + @media (width >= 640px) { + padding: 2rem; + } + @media (width >= 1024px) { + padding: 4rem; + } +} +`; + + const output = ` +:where(body:not([data-breakpoint-preview-mode])) .responsive, + :where(body:not([data-breakpoint-preview-mode])).responsive { + @media (width >= 640px) { + padding: 2rem; + } + @media (width >= 1024px) { + padding: 4rem; + } +} +.responsive { + @container (min-width: 640px) { + padding: 2rem; + } + @container (min-width: 1024px) { + padding: 4rem; + } +} +`; + + await run(plugin, input, output, opts); + }); + + describe('deep nesting with media queries', () => { + it('should apply body check at root level for two-level nested media queries', async () => { + const input = ` +.parent { + .child { + @media (min-width: 768px) { + padding: 2rem; + } + } +}`; + + const output = ` +:where(body:not([data-breakpoint-preview-mode])) .parent, + :where(body:not([data-breakpoint-preview-mode])).parent { + .child { + @media (min-width: 768px) { + padding: 2rem; + } + } +} +.parent { + .child { + @container (min-width: 768px) { + padding: 2rem; + } + } +}`; + + await run(plugin, input, output, opts); + }); + }); + + it('should apply body check at root level for three-level nested media queries', async () => { + const input = ` +.foo { + .bar { + .inside { + @media screen and (max-width: 500px) { + margin: 2rem; + } + } + } +}`; + + const output = ` +:where(body:not([data-breakpoint-preview-mode])) .foo, + :where(body:not([data-breakpoint-preview-mode])).foo { + .bar { + .inside { + @media screen and (max-width: 500px) { + margin: 2rem; + } + } + } +} +.foo { + .bar { + .inside { + @container (max-width: 500px) { + margin: 2rem; + } + } + } +}`; + + await run(plugin, input, output, opts); + }); + + it('should handle deeply nested media queries', async () => { + const input = ` +.foo { + .bar { + .inside { + @media screen and (max-width: 500px) { + margin: 2rem; + } + + color: purple; + } + @media (width > 800px) { + top: 5rem; + } + } +}`; + + const output = ` +:where(body:not([data-breakpoint-preview-mode])) .foo, + :where(body:not([data-breakpoint-preview-mode])).foo { + .bar { + .inside { + @media screen and (max-width: 500px) { + margin: 2rem; + } + + color: purple; + } + @media (width > 800px) { + top: 5rem; + } + @media (width > 800px) { + top: 5rem; + } + } +} +.foo { + .bar { + .inside { + + color: purple; + + @container (max-width: 500px) { + margin: 2rem; + } + } + @container (min-width: calc(800px + 0.02px)) { + top: 5rem; + } + } +}`; + await run(plugin, input, output, opts); + }); }); // Print-only queries