From aa8dac3642c386caac56d4e16dcd0650dc185501 Mon Sep 17 00:00:00 2001 From: Robert Means Date: Wed, 5 Nov 2025 13:58:52 -0500 Subject: [PATCH 1/3] Corrects `:where()` wrapping to preserve original specificity --- src/utils/selectorHelper.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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}`; } From a96475d48cf2126e7877a95efacf42fc8bb01b09 Mon Sep 17 00:00:00 2001 From: Robert Means Date: Wed, 5 Nov 2025 13:59:16 -0500 Subject: [PATCH 2/3] Refactors `AtRule` to account for rule nesting --- index.js | 151 ++++++++++++++++++++++++++++++++++----------- src/utils/debug.js | 22 +++++++ test/index.js | 77 ++++++++++++++++++++++- 3 files changed, 213 insertions(+), 37 deletions(-) diff --git a/index.js b/index.js index e722cd8..c3839e9 100644 --- a/index.js +++ b/index.js @@ -171,10 +171,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 +190,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 +216,125 @@ 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 ] - ) + // For nested media queries + // 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); + } }); - ruleProcessor.processDeclarations(containerRule, { - isContainer: true, + debugUtils.log(` Total declarations in container query: ${containerQuery.nodes?.length || 0}`, atRule); + + // Add container query inside the parent rule, after the media query + atRule.after(containerQuery); + + const parentRule = atRule.parent; + const originalSelector = parentRule.selector; + + // Create a new rule with the not selector wrapping + const conditionalRule = new helpers.Rule({ + selector: selectorHelper + .addTargetToSelectors( + originalSelector, + conditionalNotSelector + ), + source: parentRule.source, from: helpers.result.opts.from }); - containerRule.raws.before = '\n '; - containerRule.raws.after = '\n '; - containerRule.walkDecls(decl => { - decl.raws.before = '\n '; + // Move the media query into the conditional rule + const clonedMedia = atRule.clone(); + clonedMedia[processed] = true; // ← MARK AS PROCESSED! + conditionalRule.append(clonedMedia); + + // Add the conditional rule before the parent + parentRule.before(conditionalRule); + + // Remove the media query from the original parent + atRule.remove(); + + 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/test/index.js b/test/index.js index e1d2461..173923c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,13 +1,14 @@ const postcss = require('postcss'); const cssnano = require('cssnano'); const { equal, deepEqual } = require('node:assert'); +const assert = require('assert'); const plugin = require('../index.js'); 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 +552,80 @@ 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 result = await postcss([ plugin(opts) ]).process(input, { from: undefined }); + + // Check that container query was created + assert.ok(result.css.includes('@container (min-width: 40rem)'), + 'Should contain container query'); + + // Check that not selector was added + assert.ok(result.css.includes(':where(body:not([data-breakpoint-preview-mode]))'), + 'Should contain not selector for media query'); + + // Check that both font-size declarations exist (one in media, one in container) + const fontSizeMatches = result.css.match(/font-size: var\(--text-lg\)/g); + assert.strictEqual(fontSizeMatches.length, 2, + 'Should have font-size in both media and container queries'); + }); + + it('should handle multiple nested breakpoints', async () => { + const input = ` + .responsive { + @media (width >= 640px) { + padding: 2rem; + } + @media (width >= 1024px) { + padding: 4rem; + } + } + `; + + const result = await postcss([ plugin(opts) ]).process(input, { from: undefined }); + + // Check that both container queries were created + assert.ok(result.css.includes('@container (min-width: 640px)'), + 'Should contain first container query'); + assert.ok(result.css.includes('@container (min-width: 1024px)'), + 'Should contain second container query'); + + // Check that not selectors were added + const notSelectorMatches = result.css.match(/:where\(body:not\(\[data-breakpoint-preview-mode\]\)\)/g); + assert.ok(notSelectorMatches && notSelectorMatches.length >= 2, + 'Should have not selectors for both media queries'); + }); + + it('should preserve original selector in nested media queries', async () => { + const input = ` + .lg\\:flex { + @media (width >= 64rem) { + display: flex; + } + } + `; + + const result = await postcss([ plugin(opts) ]).process(input, { from: undefined }); + + // Check that the selector is preserved correctly (with escaped colon) + assert.ok(result.css.includes('.lg\\:flex'), + 'Should preserve escaped selector'); + + // Both versions should exist + assert.ok(result.css.includes('@media (width >= 64rem)'), + 'Should preserve media query'); + assert.ok(result.css.includes('@container (min-width: 64rem)'), + 'Should create container query'); + }); }); // Print-only queries From a72fc3280decb2281668f295354af8f8a3f94eb0 Mon Sep 17 00:00:00 2001 From: Robert Means Date: Wed, 5 Nov 2025 14:15:08 -0500 Subject: [PATCH 3/3] Update CHANGELOG --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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