Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
151 changes: 115 additions & 36 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -183,79 +190,151 @@ 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,
source: atRule.source,
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');
}
},

Expand Down
22 changes: 22 additions & 0 deletions src/utils/debug.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
};
Expand Down
10 changes: 9 additions & 1 deletion src/utils/selectorHelper.js
Original file line number Diff line number Diff line change
@@ -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 = (
Expand All @@ -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}`);
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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}`;
}
Expand Down
77 changes: 76 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
@@ -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();
});

Expand Down Expand Up @@ -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
Expand Down