├── .eslintrc ├── images ├── mobile.png └── normal.png ├── .gitignore ├── package.json ├── src ├── constants │ └── defaults.js └── utils │ ├── selectorHelper.js │ ├── debug.js │ ├── unitConverter.js │ ├── ruleProcessor.js │ └── mediaProcessor.js ├── CHANGELOG.md ├── .github └── workflows │ └── main.yml ├── README.md ├── index.js └── test └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ] 3 | } 4 | -------------------------------------------------------------------------------- /images/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/postcss-viewport-to-container-toggle/main/images/mobile.png -------------------------------------------------------------------------------- /images/normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/postcss-viewport-to-container-toggle/main/images/normal.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | 7 | # Never commit a CSS map file, anywhere 8 | *.css.map 9 | 10 | # vim swp files 11 | .*.sw* 12 | 13 | # Dont commit test generated css 14 | test/public/css/*.css 15 | test/public/css/master-*.less 16 | 17 | # Dont commit test uploads 18 | /test/data 19 | /test/public/exports 20 | /test/public/uploads 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-viewport-to-container-toggle", 3 | "version": "2.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npm run eslint && npm run mocha", 8 | "mocha": "mocha", 9 | "eslint": "eslint --ext .js,.vue ." 10 | }, 11 | "author": "Apostrophe Technologies, Inc.", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "cssnano": "^7.0.6", 15 | "eslint": "^8.57.1", 16 | "eslint-config-apostrophe": "^4.3.0", 17 | "mocha": "^10.7.3", 18 | "postcss": "^8.4.47" 19 | } 20 | } -------------------------------------------------------------------------------- /src/constants/defaults.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Default unit mappings from viewport to container query units 3 | */ 4 | const DEFAULT_UNITS = { 5 | vh: 'cqh', 6 | vw: 'cqw', 7 | vmin: 'cqmin', 8 | vmax: 'cqmax', 9 | dvh: 'cqh', 10 | dvw: 'cqw', 11 | lvh: 'cqh', 12 | lvw: 'cqw', 13 | svh: 'cqh', 14 | svw: 'cqw' 15 | }; 16 | 17 | /** 18 | * Default plugin options 19 | */ 20 | const DEFAULT_OPTIONS = { 21 | units: DEFAULT_UNITS, 22 | containerEl: 'body', 23 | modifierAttr: 'data-breakpoint-preview-mode', 24 | debug: false, 25 | transform: null, 26 | debugFilter: null 27 | }; 28 | 29 | module.exports = { 30 | DEFAULT_UNITS, 31 | DEFAULT_OPTIONS 32 | }; 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.1.0 (2025-11-25) 4 | 5 | ### Adds 6 | * Refactors the `AtRule` to handle Tailwind 4.x nesting of media queries 7 | 8 | ### Fixed 9 | 10 | * Changes the wrapping of the `:where()` pseudoclass so that it doesn't set specificity of some elements to `0`, resulting in a broken cascade. 11 | 12 | ## 2.0.1 (2025-08-06) 13 | 14 | ### Fixed 15 | 16 | * Add `:where()` pseudo-class back to fix specificity issues. 17 | 18 | ## 2.0.0 (2025-06-06) 19 | 20 | ### Adds 21 | 22 | * Supports body style in and out of media queries. Also supports body style applied to indentifiers like ids and classes if they exist on the body tag. 23 | Uses the new `[data-apos-refreshable-body]` that exists and replace the body in breakpoint preview in core. 24 | 25 | ## 1.1.0 (2025-03-19) 26 | 27 | ### Adds 28 | 29 | * Expands handling and conversion of more media queries and units 30 | 31 | ## 1.0.0 (2024-11-07) 32 | 33 | ### Adds 34 | 35 | * Creates a unique plugin to handle toggle between viewport and container mode when body has a specific attribute. 36 | * Setups mocha tests for the plugin. 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: tests 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ '*' ] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "build" 18 | build: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: [18, 20, 22] 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | - name: Git checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Use Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: ${{ matrix.node-version }} 34 | 35 | - run: npm install 36 | 37 | - run: npm test 38 | env: 39 | CI: true 40 | -------------------------------------------------------------------------------- /src/utils/selectorHelper.js: -------------------------------------------------------------------------------- 1 | const createSelectorHelper = ({ modifierAttr }) => { 2 | const bodyRegex = /^body|^html.*\s+body|^html.*\s*>\s*body/; 3 | const tagRegex = /^\.|^#|^\[|^:/; 4 | 5 | /** 6 | * Strategic use of :where() - only wrap the added targeting attributes, 7 | * not the entire selector. This preserves the original selector's specificity 8 | * while adding minimal specificity for the targeting mechanism. 9 | */ 10 | const wrapInWhere = (selector) => `:where(${selector})`; 11 | 12 | const addTargetToSelectors = ( 13 | selector, 14 | target 15 | ) => { 16 | const updatedSelector = selector 17 | .split(',') 18 | .reduce((acc, part) => { 19 | const trimmed = part.trim(); 20 | const isBodySelector = trimmed.match(bodyRegex); 21 | 22 | if (!isBodySelector) { 23 | acc.push(`${wrapInWhere(target)} ${trimmed}`); 24 | } 25 | 26 | const bodyLevelSelector = getBodyLevelSelector(trimmed, target, isBodySelector); 27 | if (bodyLevelSelector) { 28 | acc.push(bodyLevelSelector); 29 | } 30 | return acc; 31 | }, []); 32 | 33 | return updatedSelector.join(',\n '); 34 | }; 35 | 36 | const updateBodySelectors = (selector, targets) => { 37 | const updatedSelector = selector 38 | .split(',') 39 | .reduce((acc, part) => { 40 | const trimmed = part.trim(); 41 | 42 | // Should we get body level selector here? 43 | if (!trimmed.match(bodyRegex)) { 44 | return [ ...acc, trimmed ]; 45 | } 46 | 47 | const updatedPart = trimmed.replace(bodyRegex, ''); 48 | 49 | // We replace each body selector with the target, 50 | // we keep the rest of the selector 51 | return [ 52 | ...acc, 53 | ...targets.reduce((acc, target) => { 54 | return [ 55 | ...acc, 56 | `${target}${updatedPart}`.trim() 57 | ]; 58 | }, []) 59 | ]; 60 | }, []); 61 | 62 | return updatedSelector.join(',\n '); 63 | }; 64 | 65 | const getBodyLevelSelector = (selector, target, isBodySelector) => { 66 | if (isBodySelector) { 67 | selector = selector.replace(bodyRegex, ''); 68 | 69 | // Selector is a body without identifiers, we put style in the body directly 70 | // Don't wrap here since this IS the body being replaced 71 | if (!selector) { 72 | return target; 73 | } 74 | } 75 | 76 | // If selector starts by an identifier that is not a tag, we put it next to the body 77 | // in case the body has this identifier 78 | const noTagSelector = selector.match(tagRegex); 79 | if (noTagSelector) { 80 | // For body-level selectors, wrap only if it's not a body replacement 81 | const targetSelector = isBodySelector ? target : wrapInWhere(target); 82 | return `${targetSelector}${selector}`; 83 | } 84 | 85 | return null; 86 | }; 87 | 88 | return { 89 | bodyRegex, 90 | addTargetToSelectors, 91 | updateBodySelectors 92 | }; 93 | }; 94 | 95 | module.exports = createSelectorHelper; 96 | -------------------------------------------------------------------------------- /src/utils/debug.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates debug utilities for logging and tracking the processing of CSS rules. 3 | * 4 | * @param {Object} options - Configuration options for the debug utilities. 5 | * @param {boolean} options.debug - Enables or disables debug logging. 6 | * @param {string} [options.debugFilter] - A string to filter debug logs by file source. 7 | * @returns {Object} An object containing debug utilities and processing stats. 8 | */ 9 | const createDebugUtils = ({ debug, debugFilter }) => { 10 | /** 11 | * Tracks statistics about the processing of CSS rules. 12 | * 13 | * @type {Object} 14 | * @property {number} rulesProcessed - The total number of CSS rules processed. 15 | * @property {number} mediaQueriesProcessed - The total number of 16 | * media queries processed. 17 | * @property {number} fixedPositionsConverted - The total number of 18 | * fixed positions converted. 19 | * @property {Set} viewportUnitsConverted - A set of viewport units converted. 20 | * @property {Set} sourceFiles - A set of source files processed. 21 | */ 22 | const stats = { 23 | rulesProcessed: 0, 24 | mediaQueriesProcessed: 0, 25 | fixedPositionsConverted: 0, 26 | viewportUnitsConverted: new Set(), 27 | sourceFiles: new Set() 28 | }; 29 | 30 | /** 31 | * Logs a debug message to the console, including the source file if applicable. 32 | * 33 | * @param {string} message - The debug message to log. 34 | * @param {Object} node - The PostCSS node associated with the message. 35 | */ 36 | const log = (message, node) => { 37 | if (!debug) { 38 | return; 39 | } 40 | 41 | const source = node.source?.input?.file || 'unknown source'; 42 | if (debugFilter && !source.includes(debugFilter)) { 43 | return; 44 | } 45 | 46 | stats.sourceFiles.add(source); 47 | console.log(`[PostCSS Viewport to Container Toggle Plugin] ${message} (${source})`); 48 | }; 49 | 50 | const printSummary = () => { 51 | if (!debug) { 52 | return; 53 | } 54 | 55 | console.log('\n[PostCSS Viewport to Container Toggle Plugin] Processing Summary:'); 56 | console.log('----------------------------------------'); 57 | console.log('Rules processed:', stats.rulesProcessed); 58 | console.log('Media queries processed:', stats.mediaQueriesProcessed); 59 | console.log('Fixed positions converted:', stats.fixedPositionsConverted); 60 | console.log('\nViewport unit conversions:', 61 | Array.from(stats.viewportUnitsConverted).join('\n ')); 62 | console.log('\nProcessed files:', 63 | Array.from(stats.sourceFiles).join('\n ')); 64 | }; 65 | 66 | /** 67 | * Logs detailed information about media query processing 68 | */ 69 | const logMediaQuery = (atRule, context = '') => { 70 | if (!debug) { 71 | return; 72 | } 73 | 74 | const source = atRule.source?.input?.file || 'unknown source'; 75 | if (debugFilter && !source.includes(debugFilter)) { 76 | return; 77 | } 78 | 79 | console.log(`\n[Media Query ${context}] (${source})`); 80 | console.log(' Params:', atRule.params); 81 | console.log(' Parent type:', atRule.parent?.type); 82 | console.log(' Parent selector:', atRule.parent?.selector || 'N/A'); 83 | console.log(' Is nested:', atRule.parent?.type === 'rule'); 84 | console.log(' Content preview:', atRule.toString().substring(0, 200)); 85 | }; 86 | 87 | return { 88 | stats, 89 | log, 90 | logMediaQuery, 91 | printSummary 92 | }; 93 | }; 94 | 95 | module.exports = createDebugUtils; 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # postcss-viewport-to-container-toggle 2 | 3 | A plugin for [PostCSS](https://github.com/postcss/postcss) that allows to toggle between viewport and container units based on the presence of a container data attribute. 4 | 5 | ## Why? 6 | 7 | This plugin has been originally developed to allow mobile preview without using any `iframe` in order to be as close as possible to the real rendering. 8 | 9 | For examples, let's say we have a block in our page that is taking `50vw`. 10 | We want in the case of the mobile preview (where body is a container), this block to take `50cqw` instead of `50vw`. 11 | 12 | Here is what it looks like, in normal mode, our block takes `50vw` as it did before we the initial code: 13 | 14 | ![image](./images/normal.png) 15 | 16 | In mobile preview, body being a container, this block will take `50cqw` of the container: 17 | 18 | ![image](./images/mobile.png) 19 | 20 | 21 | ## Demo 22 | 23 | This css: 24 | 25 | ```css 26 | .hello { 27 | width: 100vw; 28 | height: 100vh; 29 | } 30 | ``` 31 | 32 | If you set the `modifierAttr` to `data-breakpoint-preview-mode` and `containerEl` to `body` (default), it'll be converted this way: 33 | 34 | ```css 35 | .hello { 36 | width: 100vw; 37 | height: 100vh; 38 | } 39 | 40 | :where(body[data-breakpoint-preview-mode]) .hello { 41 | width: 100cqw; 42 | height: 100cqh; 43 | } 44 | ``` 45 | 46 | The purpose being here to keep the existing behavior but to make the code work compatible for containers when body is in container mode. 47 | 48 | Here is another examples with media queries: 49 | 50 | ```css 51 | @media only screen and (width > 600px) and (max-width: 1000px) { 52 | .hello { 53 | top: 0; 54 | width: 100vw; 55 | height: calc(100vh - 50px); 56 | } 57 | .goodbye { 58 | width: 100%; 59 | color: #fff; 60 | transform: translateX(20vw); 61 | } 62 | } 63 | 64 | .toto { 65 | width: 100vh; 66 | color: white; 67 | } 68 | ``` 69 | 70 | 71 | will become: 72 | 73 | ```css 74 | @media only screen and (width > 600px) and (max-width: 1000px) { 75 | :where(body:not([data-breakpoint-preview-mode])) .hello { 76 | top: 0; 77 | width: 100vw; 78 | height: calc(100vh - 50px); 79 | } 80 | :where(body:not([data-breakpoint-preview-mode])) .goodbye { 81 | width: 100%; 82 | color: #fff; 83 | transform: translateX(20vw); 84 | } 85 | } 86 | 87 | @container (width > 600px) and (max-width: 1000px) { 88 | .hello { 89 | top: 0; 90 | width: 100cqw; 91 | height: calc(100cqh - 50px); 92 | } 93 | .goodbye { 94 | width: 100%; 95 | color: #fff; 96 | transform: translateX(20cqw); 97 | } 98 | } 99 | 100 | .toto { 101 | width: 100vh; 102 | color: white; 103 | } 104 | 105 | :where(body[data-breakpoint-preview-mode]) .toto { 106 | width: 100cqh; 107 | } 108 | ``` 109 | 110 | As you can see, if body has no specific attribute, the behavior stays the same. 111 | When adding `data-breakpoint-preview-mode`, data in media queries are converted to container units and moved to container queries. 112 | 113 | ## Installation 114 | 115 | ```bash 116 | npm install postcss-viewport-to-container-toggle 117 | ``` 118 | 119 | ## Getting started 120 | 121 | ### Webpack 122 | 123 | ```javascript 124 | const postcssViewportToContainerToggle = require('postcss-viewport-to-container-toggle'); 125 | 126 | { 127 | loader: 'postcss-loader', 128 | options: { 129 | sourceMap: true, 130 | postcssOptions: { 131 | plugins: [ 132 | [ 133 | postcssViewportToContainerToggle({ 134 | modifierAttr: 'data-breakpoint-preview-mode', 135 | containerEl: 'body', 136 | debug: false 137 | }), 138 | 'autoprefixer' 139 | ] 140 | ] 141 | } 142 | } 143 | } 144 | ``` 145 | 146 | ### Vite 147 | 148 | ```javascript 149 | const postcssViewportToContainerToggle = require('postcss-viewport-to-container-toggle'); 150 | 151 | { 152 | css: { 153 | postcss: { 154 | plugins: [ 155 | postcssViewportToContainerToggle({ 156 | modifierAttr: 'data-breakpoint-preview-mode', 157 | containerEl: 'body', 158 | debug: false 159 | }) 160 | ] 161 | } 162 | } 163 | } 164 | ``` 165 | 166 | ### Options 167 | 168 | * `modifierAttr`: The attribute that will be used to toggle between viewport and container units. 169 | * `containerEl`: The element that will be used as container. Default: `body` 170 | * `debug`: If set to `true`, will output debug information. Default: `false` 171 | * `transform`: A function that will be called for each media query, allowing to modify its params when creating the `container`. 172 | -------------------------------------------------------------------------------- /src/utils/unitConverter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a unit converter for transforming viewport units into container query units. 3 | * Includes special handling for typography-related properties and expressions. 4 | * 5 | * @param {Object} options - Configuration options for the unit converter. 6 | * @param {Object} options.units - A mapping of viewport units (e.g., `vw`, `vh`) 7 | * to container query units (e.g., `cqw`, `cqh`). 8 | * @returns {Object} An object containing methods for 9 | * unit conversion and typography handling. 10 | */ 11 | const createUnitConverter = ({ units }) => { 12 | // Special typography-specific unit mappings 13 | const TYPOGRAPHY_UNITS = { 14 | ...units, 15 | vmin: 'cqi', // Use container query inline size for typography 16 | vmax: 'cqb' // Use container query block size for typography 17 | }; 18 | 19 | /** 20 | * Parses and converts `clamp()` expressions to use container query units. 21 | * 22 | * @param {string} value - The CSS value containing a `clamp()` expression. 23 | * @returns {string} The converted value with container query units. 24 | */ 25 | const parseClampExpression = (value) => { 26 | const clampRegex = /clamp\(((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*)\)/g; 27 | return value.replace(clampRegex, (match, expression) => { 28 | const parts = expression.split(',').map(part => { 29 | part = part.trim(); 30 | if (part.includes('calc(')) { 31 | part = parseCalcExpression(part); 32 | } 33 | return convertUnitsInExpression(part); 34 | }); 35 | return `clamp(${parts.join(', ')})`; 36 | }); 37 | }; 38 | 39 | /** 40 | * Parses and converts `calc()` expressions to use container query units. 41 | * 42 | * @param {string} value - The CSS value containing a `calc()` expression. 43 | * @returns {string} The converted value with container query units. 44 | */ 45 | const parseCalcExpression = (value) => { 46 | const calcRegex = /calc\(((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*)\)/g; 47 | return value.replace(calcRegex, (match, expression) => { 48 | return `calc(${convertUnitsInExpression(expression)})`; 49 | }); 50 | }; 51 | 52 | /** 53 | * Converts viewport units in a CSS expression to container query units. 54 | * 55 | * @param {string} expression - The CSS expression containing viewport units. 56 | * @param {boolean} [isTypography=false] - Whether to use 57 | * typography-specific unit mappings. 58 | * @returns {string} The converted expression with container query units. 59 | */ 60 | const convertUnitsInExpression = (expression, isTypography = false) => { 61 | // Determine which unit mappings to use 62 | const unitMappings = isTypography ? TYPOGRAPHY_UNITS : units; 63 | 64 | // Handle fluid typography patterns first 65 | expression = expression.replace( 66 | /(\d*\.?\d+)vw\s*\+\s*(\d*\.?\d+)rem/g, 67 | (match, vw, rem) => `${rem}rem + ${vw}cqw` 68 | ); 69 | 70 | // Convert standard units 71 | return Object.entries(unitMappings).reduce((acc, [ unit, containerUnit ]) => { 72 | const unitRegex = new RegExp(`(\\d*\\.?\\d+)${unit}`, 'g'); 73 | return acc.replace(unitRegex, `$1${containerUnit}`); 74 | }, expression); 75 | }; 76 | 77 | /** 78 | * Processes typography-specific values by converting units and parsing expressions. 79 | * 80 | * @param {string} value - The typography-related CSS value to process. 81 | * @returns {string} The processed value with container query units. 82 | */ 83 | const processTypographyValue = (value) => { 84 | let processed = value; 85 | 86 | if (value.includes('clamp(')) { 87 | processed = parseClampExpression(processed); 88 | } 89 | 90 | if (processed.includes('calc(')) { 91 | processed = parseCalcExpression(processed); 92 | } 93 | 94 | // Use typography-specific unit conversion 95 | processed = convertUnitsInExpression(processed, true); 96 | 97 | return processed; 98 | }; 99 | 100 | /** 101 | * Checks if a CSS property is typography-related. 102 | * 103 | * @param {string} prop - The name of the CSS property to check. 104 | * @returns {boolean} Returns true if the property is 105 | * typography-related, otherwise false. 106 | */ 107 | const isTypographyProperty = (prop) => { 108 | return [ 109 | 'font-size', 110 | 'line-height', 111 | 'letter-spacing', 112 | 'word-spacing', 113 | 'text-indent', 114 | 'margin-top', 115 | 'margin-bottom', 116 | 'padding-top', 117 | 'padding-bottom' 118 | ].includes(prop); 119 | }; 120 | 121 | return { 122 | units, 123 | parseClampExpression, 124 | parseCalcExpression, 125 | convertUnitsInExpression, 126 | processTypographyValue, 127 | isTypographyProperty 128 | }; 129 | }; 130 | 131 | module.exports = createUnitConverter; 132 | -------------------------------------------------------------------------------- /src/utils/ruleProcessor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a rule processor for handling CSS rules, including viewport unit conversions 3 | * and fixed position handling. 4 | * 5 | * @param {Object} options - Configuration options for the rule processor. 6 | * @param {Object} options.unitConverter - Handles unit conversions 7 | * for viewport and typography units. 8 | * @returns {Object} An object containing methods to process and check CSS rules. 9 | */ 10 | const createRuleProcessor = ({ unitConverter }) => { 11 | const { units } = unitConverter; 12 | 13 | /** 14 | * Checks if a CSS rule requires processing. 15 | * 16 | * A rule requires processing if: 17 | * - It contains a `position: fixed` declaration. 18 | * - It includes any declarations using viewport units (e.g., `vw`, `vh`). 19 | * 20 | * @param {Object} rule - The PostCSS rule to check. 21 | * @returns {boolean} Returns true if the rule needs processing, otherwise false. 22 | */ 23 | const needsProcessing = (rule) => { 24 | // Check for fixed position or viewport units 25 | let needsConversion = false; 26 | 27 | rule.walkDecls(decl => { 28 | // Check for fixed position 29 | if (decl.prop === 'position' && decl.value === 'fixed') { 30 | needsConversion = true; 31 | // eslint-disable-next-line brace-style 32 | } 33 | 34 | // Check for viewport units 35 | else if (Object.keys(units).some(unit => decl.value.includes(unit))) { 36 | needsConversion = true; 37 | } 38 | }); 39 | 40 | return needsConversion; 41 | }; 42 | 43 | /** 44 | * Processes the declarations within a CSS rule. 45 | * 46 | * - Converts `position: fixed` to `position: sticky` for container queries. 47 | * - Rewrites position-related properties (e.g., `top`, `left`) to use CSS variables 48 | * when `isContainer` is true. 49 | * - Converts viewport units to container-relative units where applicable. 50 | * 51 | * @param {Object} rule - The PostCSS rule to process. 52 | * @param {Object} [options={}] - Additional processing options. 53 | * @param {boolean} [options.isContainer=false] - Indicates if the rule 54 | * is being processed as part of a container query. 55 | * @param {string} [options.from] - Source file path for PostCSS processing. 56 | * @returns {Object} An object containing a flag indicating 57 | * if the rule had a fixed position. 58 | */ 59 | const processDeclarations = (rule, { isContainer = false, from } = {}) => { 60 | let hasFixedPosition = false; 61 | 62 | // First pass: check for fixed position 63 | rule.walkDecls('position', decl => { 64 | if (decl.value === 'fixed') { 65 | hasFixedPosition = true; 66 | if (isContainer) { 67 | decl.value = 'sticky'; 68 | } 69 | } 70 | }); 71 | 72 | // Second pass: process all declarations 73 | if (isContainer && hasFixedPosition) { 74 | // For fixed position elements, we need to handle position-related props first 75 | [ 'top', 'right', 'bottom', 'left' ].forEach(prop => { 76 | rule.walkDecls(prop, decl => { 77 | // Add the CSS custom property declaration with proper source tracking 78 | const varName = `--container-${prop}`; 79 | rule.insertBefore(decl, { 80 | prop: varName, 81 | value: decl.value, 82 | source: decl.source, 83 | from 84 | }); 85 | // Update the original declaration to use the variable 86 | decl.value = `var(${varName})`; 87 | }); 88 | }); 89 | } 90 | 91 | rule.walkDecls(decl => { 92 | // Skip position-related props we've already handled 93 | if (hasFixedPosition && [ 'position', 'top', 'right', 'bottom', 'left' ].includes(decl.prop)) { 94 | return; 95 | } 96 | 97 | let value = decl.value; 98 | let needsConversion = false; 99 | 100 | // Handle typography properties 101 | if (unitConverter.isTypographyProperty(decl.prop)) { 102 | const newValue = unitConverter.processTypographyValue(value); 103 | if (newValue !== value) { 104 | value = newValue; 105 | needsConversion = true; 106 | } 107 | } 108 | 109 | // Handle viewport units 110 | if (Object.keys(units).some(unit => value.includes(unit))) { 111 | value = unitConverter.convertUnitsInExpression(value); 112 | needsConversion = true; 113 | } 114 | 115 | if (needsConversion) { 116 | // Create a new declaration with proper source tracking 117 | const newDecl = decl.clone({ 118 | value, 119 | source: decl.source, 120 | from 121 | }); 122 | decl.replaceWith(newDecl); 123 | } 124 | }); 125 | 126 | return { hasFixedPosition }; 127 | }; 128 | 129 | return { 130 | needsProcessing, 131 | processDeclarations 132 | }; 133 | }; 134 | 135 | module.exports = createRuleProcessor; 136 | -------------------------------------------------------------------------------- /src/utils/mediaProcessor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a media query processor to handle media queries 3 | * and convert them into container queries. 4 | * 5 | * @param {Object} options - Configuration options for the processor. 6 | * @param {Function} options.transform - A custom function to transform media queries. 7 | * @returns {Object} An object containing methods to process media queries. 8 | */ 9 | const createMediaProcessor = ({ transform }) => { 10 | 11 | /** 12 | * Converts a single comparison into a min/max format. 13 | * 14 | * @param {string} value - The value to compare, e.g., `500px`. 15 | * @param {string} property - The property being compared, e.g., `width`. 16 | * @param {string} operator - The comparison operator, e.g., `>=`, `<=`. 17 | * @returns {string} The converted comparison in min/max format. 18 | */ 19 | const convertComparison = (value, property, operator) => { 20 | // Simple lookup table for operator conversion 21 | const operatorMap = { 22 | '>=': 'min', 23 | '<=': 'max', 24 | '>': value => `min-${property}: calc(${value} + 0.02px)`, 25 | '<': value => `max-${property}: calc(${value} - 0.02px)` 26 | }; 27 | 28 | if (typeof operatorMap[operator] === 'function') { 29 | return operatorMap[operator](value); 30 | } 31 | 32 | return `${operatorMap[operator]}-${property}: ${value}`; 33 | }; 34 | 35 | /** 36 | * Converts a media query range syntax into min/max format. 37 | * 38 | * @param {string} feature - The media query condition to convert. 39 | * @returns {string} The condition converted into min/max format. 40 | */ 41 | const convertRangeSyntax = (feature) => { 42 | // Split on 'and' to process each condition independently 43 | const conditions = feature.trim().split(' and ').map(cond => cond.trim()); 44 | 45 | const convertedConditions = conditions.map(cond => { 46 | // Full range: (100px <= width <= 200px) or (100px < width < 200px) 47 | const fullRangeMatch = cond.match( 48 | /(?\d+[a-z%]*)\s*(?[<>]=?)\s*(?[a-z-]+)\s*(?[<>]=?)\s*(?\d+[a-z%]*)/ 49 | ); 50 | if (fullRangeMatch) { 51 | const { 52 | min, 53 | minOp, 54 | property, 55 | maxOp, 56 | max 57 | } = fullRangeMatch.groups; 58 | const minCondition = convertComparison(min, property, minOp === '<=' ? '>=' : minOp === '>=' ? '<=' : minOp === '<' ? '>' : '<'); 59 | const maxCondition = convertComparison(max, property, maxOp); 60 | return `${minCondition}) and (${maxCondition}`; 61 | } 62 | 63 | // Single comparison: (width >= 100px) or (width < 200px) 64 | const singleComparisonMatch = cond.match( 65 | /(?[a-z-]+)\s*(?[<>]=?)\s*(?\d+[a-z%]*)/ 66 | ) || cond.match( 67 | /(?\d+[a-z%]*)\s*(?[<>]=?)\s*(?[a-z-]+)/ 68 | ); 69 | 70 | if (singleComparisonMatch) { 71 | let { 72 | property, 73 | operator, 74 | value 75 | } = singleComparisonMatch.groups; 76 | 77 | // If the number comes first (we matched the second pattern) 78 | if (/^\d/.test(property)) { 79 | // Swap property and value 80 | [ property, value ] = [ value, property ]; 81 | 82 | // Map the operator to its inverse 83 | const operatorMap = { 84 | '>=': '<=', 85 | '<=': '>=', 86 | '>': '<', 87 | '<': '>' 88 | }; 89 | 90 | operator = operatorMap[operator] || operator; 91 | } 92 | return `(${convertComparison(value, property, operator)})`; 93 | } 94 | 95 | // Handle standard media feature formats with colon 96 | if (cond.includes(':')) { 97 | return cond; 98 | } 99 | 100 | return cond; 101 | }); 102 | 103 | return convertedConditions.join(' and '); 104 | }; 105 | 106 | /** 107 | * Splits media query parameters into individual conditions. 108 | * 109 | * @param {string} params - The media query string to split. 110 | * @returns {string[]} An array of individual media conditions. 111 | */ 112 | const splitMediaConditions = (params) => { 113 | return params.split(',').map(condition => condition.trim()); 114 | }; 115 | 116 | /** 117 | * Check if a condition is screen/all related (vs print) 118 | * 119 | * @param {string} condition - The media condition string to check. 120 | * @returns {boolean} Returns true if the condition is related to `screen` or `all`. 121 | */ 122 | const isScreenCondition = (condition) => { 123 | return ( 124 | !condition.includes('print') && 125 | (condition.includes('screen') || 126 | condition.includes('all') || 127 | !/(all|screen|print)/.test(condition)) 128 | ); 129 | }; 130 | 131 | /** 132 | * Check if a condition includes query conditions like min/max width or height. 133 | * 134 | * @param {string} condition - The media condition string to check. 135 | * @returns {boolean} Returns true if the condition includes query-related properties. 136 | */ 137 | const hasQueryConditions = (condition) => { 138 | return /min-|max-|width|height|orientation/.test(condition); 139 | }; 140 | 141 | /** 142 | * Cleans a media condition by removing keywords 143 | * like `only`, `screen`, `all`, and `print`. 144 | * 145 | * @param {string} condition - The media condition string to clean. 146 | * @returns {string} The cleaned condition. 147 | */ 148 | const cleanMediaCondition = (condition) => { 149 | return condition 150 | .replace(/(only\s*)?(all|screen|print)(,)?(\s)*(and\s*)?/g, '') 151 | .trim(); 152 | }; 153 | 154 | /** 155 | * Extracts media conditions from a rule, including nested conditions. 156 | * 157 | * @param {Object} atRule - The PostCSS `@media` rule to process. 158 | * @returns {string[]} An array of cleaned media conditions. 159 | */ 160 | const getMediaConditions = (atRule) => { 161 | const conditions = []; 162 | 163 | // First check this level's conditions 164 | const mediaConditions = splitMediaConditions(atRule.params); 165 | 166 | for (const condition of mediaConditions) { 167 | if (isScreenCondition(condition)) { 168 | if (hasQueryConditions(condition)) { 169 | const cleaned = cleanMediaCondition(condition); 170 | if (cleaned) { 171 | conditions.push(cleaned); 172 | } 173 | } 174 | } 175 | } 176 | 177 | // Then process any nested media queries 178 | atRule.walkAtRules('media', (nested) => { 179 | const nestedConditions = splitMediaConditions(nested.params); 180 | 181 | for (const condition of nestedConditions) { 182 | if (isScreenCondition(condition)) { 183 | if (hasQueryConditions(condition)) { 184 | const cleaned = cleanMediaCondition(condition); 185 | if (cleaned) { 186 | conditions.push(cleaned); 187 | } 188 | } 189 | } 190 | } 191 | }); 192 | 193 | if (conditions.length > 1) { 194 | return [ conditions.filter(Boolean).join(' and ') ]; 195 | } 196 | 197 | return conditions.filter(Boolean); 198 | }; 199 | 200 | /** 201 | * Converts an array of media conditions into container query conditions. 202 | * 203 | * @param {string[]} conditions - An array of media conditions to convert. 204 | * @returns {string|null} A string containing the container query conditions. 205 | */ 206 | const convertToContainerConditions = (conditions) => { 207 | if (!conditions.length) { 208 | return null; 209 | } 210 | 211 | // Join all conditions with 'and' 212 | const combinedCondition = conditions.filter(Boolean).join(' and '); 213 | 214 | let containerQuery = typeof transform === 'function' 215 | ? transform(combinedCondition) 216 | : combinedCondition; 217 | 218 | // Convert range syntax if needed 219 | containerQuery = convertRangeSyntax(containerQuery); 220 | 221 | // Ensure proper parentheses 222 | if (!containerQuery.startsWith('(')) { 223 | containerQuery = `(${containerQuery}`; 224 | } 225 | if (!containerQuery.endsWith(')')) { 226 | containerQuery = `${containerQuery})`; 227 | } 228 | 229 | return containerQuery.trim(); 230 | }; 231 | 232 | return { 233 | getMediaConditions, 234 | convertToContainerConditions 235 | }; 236 | }; 237 | 238 | module.exports = createMediaProcessor; 239 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * PostCSS plugin to toggle viewport units into container query units. 3 | * 4 | * This plugin processes CSS rules and media queries to add container query versions 5 | * alongside the existing viewport versions. It includes support for: 6 | * - Converting `position: fixed` to `position: sticky` in container contexts. 7 | * - Handling nested media queries. 8 | * - Adding container query contexts when required. 9 | * 10 | * @param {Object} opts - Plugin options. 11 | * @param {Object} [opts.units] - A mapping of viewport units to container query units. 12 | * @param {string} [opts.containerEl='body'] - The container element selector. 13 | * @param {string} [opts.modifierAttr='data-breakpoint-preview-mode'] 14 | * - The attribute for container queries. 15 | * @param {Function} [opts.transform] - A custom function to transform 16 | * media queries when creating container queries. 17 | * @param {boolean} [opts.debug=false] - Enables debug logging. 18 | * @param {string} [opts.debugFilter] - A filter string for limiting debug 19 | * logs to specific files. 20 | * @returns {Object} The PostCSS plugin. 21 | */ 22 | const { DEFAULT_OPTIONS } = require('./src/constants/defaults'); 23 | const createUnitConverter = require('./src/utils/unitConverter'); 24 | const createMediaProcessor = require('./src/utils/mediaProcessor'); 25 | const createRuleProcessor = require('./src/utils/ruleProcessor'); 26 | const createDebugUtils = require('./src/utils/debug'); 27 | const createSelectorHelper = require('./src/utils/selectorHelper'); 28 | 29 | const plugin = (opts = {}) => { 30 | // Merge options with defaults 31 | const options = { 32 | ...DEFAULT_OPTIONS, 33 | ...opts 34 | }; 35 | const { containerEl, modifierAttr } = options; 36 | 37 | // Create selectors 38 | const conditionalSelector = `${containerEl}[${modifierAttr}]`; 39 | const conditionalNotSelector = `${containerEl}:not([${modifierAttr}])`; 40 | const containerBodySelector = '[data-apos-refreshable-body]'; 41 | 42 | // Create utility instances 43 | const unitConverter = createUnitConverter({ units: options.units }); 44 | const debugUtils = createDebugUtils(options); 45 | const mediaProcessor = createMediaProcessor({ 46 | ...options 47 | }); 48 | const ruleProcessor = createRuleProcessor({ 49 | ...options, 50 | unitConverter 51 | }); 52 | const selectorHelper = createSelectorHelper({ modifierAttr }); 53 | 54 | // Track processed nodes to avoid duplicates 55 | const processed = Symbol('processed'); 56 | 57 | // Flag to track if container context like sticky has been added 58 | let hasAddedContainerContext = false; 59 | 60 | const isSameMediaQuery = (mq1, mq2) => { 61 | return mq1.params === mq2.params && 62 | mq1.source?.start?.line === mq2.source?.start?.line; 63 | }; 64 | 65 | /** 66 | * Adds a container context with `position: relative` and `contain: layout` if required. 67 | * 68 | * @param {Object} root - The PostCSS root node. 69 | * @param {Object} helpers - PostCSS helpers, including the `Rule` constructor. 70 | */ 71 | const addContainerContextIfNeeded = (root, helpers) => { 72 | if (hasAddedContainerContext) { 73 | return; 74 | } 75 | 76 | let needsContainerContext = false; 77 | root.walkDecls('position', decl => { 78 | if (decl.value === 'fixed') { 79 | needsContainerContext = true; 80 | return false; 81 | } 82 | }); 83 | 84 | if (needsContainerContext) { 85 | debugUtils.stats.fixedPositionsConverted++; 86 | const contextRule = new helpers.Rule({ 87 | selector: conditionalSelector, 88 | source: root.source, 89 | from: helpers.result.opts.from 90 | }); 91 | contextRule.append({ 92 | prop: 'position', 93 | value: 'relative', 94 | source: root.source, 95 | from: helpers.result.opts.from 96 | }); 97 | contextRule.append({ 98 | prop: 'contain', 99 | value: 'layout', 100 | source: root.source, 101 | from: helpers.result.opts.from 102 | }); 103 | root.prepend(contextRule); 104 | hasAddedContainerContext = true; 105 | } 106 | }; 107 | 108 | return { 109 | postcssPlugin: 'postcss-viewport-to-container-toggle', 110 | 111 | Once(root, helpers) { 112 | addContainerContextIfNeeded(root, helpers); 113 | }, 114 | 115 | Rule(rule, helpers) { 116 | // Skip already processed rules 117 | if (rule[processed]) { 118 | return; 119 | } 120 | 121 | // Skip rules inside media queries - these will be handled by AtRule 122 | // as well as the ones inside container queries (already processed or from css) 123 | if ( 124 | rule.parent?.type === 'atrule' && 125 | [ 'media', 'container' ].includes(rule.parent?.name) 126 | ) { 127 | return; 128 | } 129 | 130 | // Do not treat cloned rules already handled 131 | if ( 132 | rule.selector.includes(conditionalNotSelector) || 133 | rule.selector.includes(containerBodySelector) || 134 | rule.selector.includes(conditionalSelector) 135 | ) { 136 | return; 137 | } 138 | 139 | // Process rule if it needs conversion 140 | if (ruleProcessor.needsProcessing(rule)) { 141 | debugUtils.stats.rulesProcessed++; 142 | debugUtils.log(`Processing rule: ${rule.selector}`, rule); 143 | 144 | // Create container version with converted units 145 | // should target [data-apos-refreshable-body] 146 | const containerRule = rule.clone({ 147 | source: rule.source, 148 | from: helpers.result.opts.from, 149 | selector: selectorHelper.addTargetToSelectors( 150 | rule.selector, 151 | containerBodySelector 152 | ) 153 | }); 154 | 155 | rule.selector = selectorHelper.updateBodySelectors( 156 | rule.selector, 157 | [ conditionalNotSelector ] 158 | ); 159 | 160 | ruleProcessor.processDeclarations(containerRule, { 161 | isContainer: true, 162 | from: helpers.result.opts.from 163 | }); 164 | 165 | // Add container rule after original 166 | rule.after('\n' + containerRule); 167 | } else { 168 | rule.selector = selectorHelper.updateBodySelectors( 169 | rule.selector, 170 | [ conditionalNotSelector, containerBodySelector ] 171 | ); 172 | } 173 | 174 | rule[processed] = true; 175 | }, 176 | 177 | AtRule: { 178 | media(atRule, helpers) { 179 | debugUtils.logMediaQuery(atRule, 'START'); 180 | 181 | if (atRule[processed]) { 182 | debugUtils.log('Skipping already processed media query', atRule); 183 | return; 184 | } 185 | 186 | // Check if this media query is nested inside a rule 187 | const isNested = atRule.parent?.type === 'rule'; 188 | debugUtils.log(`Media query is ${isNested ? 'NESTED' : 'TOP-LEVEL'}`, atRule); 189 | 190 | let hasNotSelector = false; 191 | atRule.walkRules(rule => { 192 | if (rule.selector.includes(conditionalNotSelector)) { 193 | hasNotSelector = true; 194 | } 195 | }); 196 | 197 | if (hasNotSelector) { 198 | debugUtils.log('Skipping - already has not selector', atRule); 199 | atRule[processed] = true; 200 | return; 201 | } 202 | 203 | const conditions = mediaProcessor.getMediaConditions(atRule); 204 | debugUtils.log(`Extracted conditions: ${JSON.stringify(conditions)}`, atRule); 205 | 206 | if (conditions.length > 0) { 207 | debugUtils.stats.mediaQueriesProcessed++; 208 | 209 | const containerConditions = 210 | mediaProcessor.convertToContainerConditions(conditions); 211 | 212 | debugUtils.log(`Container conditions: ${containerConditions}`, atRule); 213 | 214 | if (containerConditions) { 215 | debugUtils.log('Creating container query...', atRule); 216 | 217 | const containerQuery = new helpers.AtRule({ 218 | name: 'container', 219 | params: containerConditions, 220 | source: atRule.source, 221 | from: helpers.result.opts.from 222 | }); 223 | 224 | // For nested media queries 225 | if (isNested) { 226 | debugUtils.log('Processing nested media query declarations...', atRule); 227 | 228 | atRule.each(node => { 229 | if (node.type === 'decl') { 230 | debugUtils.log(` Processing declaration: ${node.prop}: ${node.value}`, atRule); 231 | 232 | const containerDecl = node.clone({ 233 | source: node.source, 234 | from: helpers.result.opts.from 235 | }); 236 | 237 | // Convert viewport units if needed 238 | let value = containerDecl.value; 239 | if (Object.keys(unitConverter.units) 240 | .some(unit => value.includes(unit))) { 241 | value = unitConverter.convertUnitsInExpression(value); 242 | containerDecl.value = value; 243 | debugUtils.log(` Converted value to: ${value}`, atRule); 244 | } 245 | 246 | containerQuery.append(containerDecl); 247 | } 248 | }); 249 | 250 | debugUtils.log(` Total declarations in container query: ${containerQuery.nodes?.length || 0}`, atRule); 251 | 252 | const parentRule = atRule.parent; 253 | 254 | // Find the root nesting level 255 | let rootParent = parentRule; 256 | let isSingleLevel = true; 257 | while (rootParent.parent && rootParent.parent.type === 'rule') { 258 | rootParent = rootParent.parent; 259 | isSingleLevel = false; 260 | } 261 | 262 | // For single-level nesting (Tailwind case), use simple approach 263 | if (isSingleLevel) { 264 | // Add container query inside the parent rule, after the media query 265 | atRule.after(containerQuery); 266 | 267 | const originalSelector = parentRule.selector; 268 | 269 | let conditionalRule = null; 270 | let alreadyHasWrapper = false; 271 | 272 | let prevNode = parentRule.prev(); 273 | const targetSelector = selectorHelper 274 | .addTargetToSelectors( 275 | originalSelector, 276 | conditionalNotSelector 277 | ); 278 | 279 | while (prevNode) { 280 | if (prevNode.type === 'rule' && prevNode.selector === targetSelector) { 281 | conditionalRule = prevNode; 282 | alreadyHasWrapper = true; 283 | debugUtils.log('Found existing conditional wrapper, reusing it', atRule); 284 | break; 285 | } 286 | prevNode = prevNode.prev(); 287 | } 288 | 289 | if (!alreadyHasWrapper) { 290 | conditionalRule = new helpers.Rule({ 291 | selector: targetSelector, 292 | source: parentRule.source, 293 | from: helpers.result.opts.from 294 | }); 295 | 296 | parentRule.before(conditionalRule); 297 | debugUtils.log('Created new conditional wrapper', atRule); 298 | } 299 | 300 | const clonedMedia = atRule.clone(); 301 | clonedMedia[processed] = true; 302 | conditionalRule.append(clonedMedia); 303 | atRule.remove(); 304 | 305 | debugUtils.log('Added conditional wrapper for nested media query', atRule); 306 | 307 | } else { 308 | // Multi-level nesting - hoist to root with full structure 309 | const rootSelector = rootParent.selector; 310 | 311 | // Check if wrapper exists at root level 312 | let conditionalRule = null; 313 | let alreadyHasWrapper = false; 314 | 315 | let prevNode = rootParent.prev(); 316 | const targetSelector = selectorHelper 317 | .addTargetToSelectors( 318 | rootSelector, 319 | conditionalNotSelector 320 | ); 321 | 322 | while (prevNode) { 323 | if (prevNode.type === 'rule' && prevNode.selector === targetSelector) { 324 | conditionalRule = prevNode; 325 | alreadyHasWrapper = true; 326 | debugUtils.log('Found existing conditional wrapper, reusing it', atRule); 327 | break; 328 | } 329 | prevNode = prevNode.prev(); 330 | } 331 | 332 | if (!alreadyHasWrapper) { 333 | // Create wrapper with full nested structure 334 | conditionalRule = new helpers.Rule({ 335 | selector: targetSelector, 336 | source: rootParent.source, 337 | from: helpers.result.opts.from 338 | }); 339 | 340 | // Clone children of root parent (before container query is added) 341 | rootParent.each(node => { 342 | const clonedNode = node.clone(); 343 | 344 | // Remove media queries that are NOT the current one being processed 345 | clonedNode.walkAtRules('media', (mediaRule) => { 346 | // Keep the current media query we're processing, remove others 347 | if (mediaRule !== atRule && !isSameMediaQuery(mediaRule, atRule)) { 348 | mediaRule.remove(); 349 | } else { 350 | mediaRule[processed] = true; 351 | } 352 | }); 353 | 354 | // If this node itself is a media query 355 | // and not the one we're processing, skip it 356 | if (clonedNode.type === 'atrule' && clonedNode.name === 'media') { 357 | if (!isSameMediaQuery(clonedNode, atRule)) { 358 | return; // Skip appending 359 | } 360 | clonedNode[processed] = true; 361 | } 362 | 363 | conditionalRule.append(clonedNode); 364 | }); 365 | 366 | rootParent.before(conditionalRule); 367 | debugUtils.log('Created new conditional wrapper at root level', atRule); 368 | } else { 369 | // Wrapper exists, add media query to matching nested location 370 | const targetInWrapper = conditionalRule.first; 371 | if (targetInWrapper && targetInWrapper.type === 'rule') { 372 | // Build path from parentRule to rootParent 373 | const pathSelectors = []; 374 | let current = parentRule; 375 | while (current !== rootParent) { 376 | pathSelectors.unshift(current.selector); 377 | current = current.parent; 378 | } 379 | 380 | // Navigate to matching location in wrapper 381 | let navNode = targetInWrapper; 382 | for (const selector of pathSelectors) { 383 | let found = false; 384 | navNode.each(node => { 385 | if (node.type === 'rule' && node.selector === selector) { 386 | navNode = node; 387 | found = true; 388 | return false; 389 | } 390 | }); 391 | if (!found) { 392 | break; 393 | } 394 | } 395 | 396 | // Add cloned media to correct location 397 | const clonedMedia = atRule.clone(); 398 | clonedMedia[processed] = true; 399 | navNode.append(clonedMedia); 400 | } 401 | } 402 | 403 | // Remove from original 404 | atRule.remove(); 405 | 406 | // Now add container query to original (after cloning) 407 | parentRule.append(containerQuery); 408 | 409 | debugUtils.log('Added conditional wrapper for nested media query', atRule); 410 | } 411 | 412 | } else { 413 | // Original logic for top-level media queries 414 | atRule.walkRules(rule => { 415 | const containerRule = rule.clone({ 416 | source: rule.source, 417 | from: helpers.result.opts.from, 418 | selector: selectorHelper.updateBodySelectors( 419 | rule.selector, 420 | [ containerBodySelector ] 421 | ) 422 | }); 423 | 424 | ruleProcessor.processDeclarations(containerRule, { 425 | isContainer: true, 426 | from: helpers.result.opts.from 427 | }); 428 | 429 | containerRule.raws.before = '\n '; 430 | containerRule.raws.after = '\n '; 431 | containerRule.walkDecls(decl => { 432 | decl.raws.before = '\n '; 433 | }); 434 | 435 | containerQuery.append(containerRule); 436 | }); 437 | 438 | // Add container query 439 | atRule.after(containerQuery); 440 | } 441 | } 442 | 443 | // Now handle viewport media query modifications 444 | // We want the original media query to get the not selector 445 | if (!isNested) { 446 | atRule.walkRules(rule => { 447 | // Skip if already modified with not selector 448 | if (rule.selector.includes(conditionalNotSelector)) { 449 | return; 450 | } 451 | 452 | const viewportRule = rule.clone({ 453 | source: rule.source, 454 | from: helpers.result.opts.from 455 | }); 456 | 457 | viewportRule.selector = selectorHelper.addTargetToSelectors( 458 | rule.selector, 459 | conditionalNotSelector 460 | ); 461 | 462 | rule.replaceWith(viewportRule); 463 | }); 464 | } 465 | } else { 466 | debugUtils.log('No conditions found - skipping', atRule); 467 | } 468 | 469 | // Only mark the atRule as processed after all transformations 470 | atRule[processed] = true; 471 | debugUtils.logMediaQuery(atRule, 'END'); 472 | } 473 | }, 474 | 475 | OnceExit() { 476 | debugUtils.printSummary(); 477 | } 478 | }; 479 | }; 480 | 481 | plugin.postcss = true; 482 | 483 | module.exports = plugin; 484 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss'); 2 | const cssnano = require('cssnano'); 3 | const { equal, deepEqual } = require('node:assert'); 4 | const plugin = require('../index.js'); 5 | const opts = { modifierAttr: 'data-breakpoint-preview-mode' }; 6 | 7 | let currentFileName = ''; 8 | 9 | // Hook into Mocha's test context 10 | beforeEach(function () { 11 | currentFileName = this.currentTest.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); 12 | }); 13 | 14 | async function formatCSS(css) { 15 | const result = await postcss([ cssnano({ preset: 'default' }) ]) 16 | .process(css, { 17 | from: `${currentFileName}_formatted.css` 18 | }); 19 | return result.css.trim(); 20 | } 21 | 22 | // Enhanced run helper with detailed output on failure 23 | async function run(plugin, input, output, opts = {}) { 24 | const result = await postcss([ plugin(opts) ]) 25 | .process(input, { 26 | from: `${currentFileName}.css` 27 | }); 28 | 29 | try { 30 | // Normalize both expected and actual CSS before comparison 31 | const formattedResult = await formatCSS(result.css); 32 | const formattedOutput = await formatCSS(output); 33 | 34 | equal(formattedResult, formattedOutput); 35 | deepEqual(result.warnings(), []); 36 | } catch (error) { 37 | console.log('\n=== Test Failed ==='); 38 | console.log('Input:'); 39 | console.log(input); 40 | console.log('\nExpected Output (Formatted):'); 41 | console.log(output); 42 | console.log('\nActual Output:'); 43 | console.log(result.css); 44 | 45 | throw error; 46 | } 47 | } 48 | 49 | describe('postcss-viewport-to-container-toggle additional features', () => { 50 | // Typography-related tests 51 | describe('typography features', () => { 52 | it('should convert viewport units in typography-related properties', async () => { 53 | const input = ` 54 | .text { 55 | font-size: calc(16px + 2vw); 56 | line-height: calc(1.5 + 1vh); 57 | letter-spacing: 0.5vmin; 58 | }`; 59 | const output = ` 60 | .text { 61 | font-size: calc(16px + 2vw); 62 | line-height: calc(1.5 + 1vh); 63 | letter-spacing: 0.5vmin; 64 | } 65 | :where([data-apos-refreshable-body]) .text, 66 | :where([data-apos-refreshable-body]).text { 67 | font-size: calc(16px + 2cqw); 68 | line-height: calc(1.5 + 1cqh); 69 | letter-spacing: 0.5cqi; 70 | } 71 | `; 72 | 73 | await run(plugin, input, output, opts); 74 | }); 75 | 76 | it('should handle clamp expressions in typography', async () => { 77 | const input = ` 78 | .fluid-text { 79 | font-size: clamp(1rem, 2vw + 1rem, 3rem); 80 | line-height: clamp(1.2, calc(1 + 2vh), 1.8); 81 | }`; 82 | const output = ` 83 | .fluid-text { 84 | font-size: clamp(1rem, 2vw + 1rem, 3rem); 85 | line-height: clamp(1.2, calc(1 + 2vh), 1.8); 86 | } 87 | :where([data-apos-refreshable-body]) .fluid-text, 88 | :where([data-apos-refreshable-body]).fluid-text { 89 | font-size: clamp(1rem, 1rem + 2cqw, 3rem); 90 | line-height: clamp(1.2, calc(1 + 2cqh), 1.8); 91 | }`; 92 | 93 | await run(plugin, input, output, opts); 94 | }); 95 | 96 | it('should ignore non-typography properties', async () => { 97 | const input = ` 98 | .foo { 99 | color: red; 100 | font-size: 2vw; 101 | }`; 102 | const output = ` 103 | .foo { 104 | color: red; 105 | font-size: 2vw; 106 | } 107 | :where([data-apos-refreshable-body]) .foo, 108 | :where([data-apos-refreshable-body]).foo { 109 | color: red; 110 | font-size: 2cqw; 111 | }`; 112 | 113 | await run(plugin, input, output, opts); 114 | }); 115 | 116 | it('should handle decimal viewport values in typography', async () => { 117 | const input = ` 118 | .decimals { 119 | font-size: 2.75vw; 120 | }`; 121 | const output = ` 122 | .decimals { 123 | font-size: 2.75vw; 124 | } 125 | :where([data-apos-refreshable-body]) .decimals, 126 | :where([data-apos-refreshable-body]).decimals { 127 | font-size: 2.75cqw; 128 | }`; 129 | 130 | await run(plugin, input, output, opts); 131 | }); 132 | 133 | it('should handle zero values in typography (should remain zero)', async () => { 134 | const input = ` 135 | .zero-test { 136 | font-size: 0vw; 137 | }`; 138 | const output = ` 139 | .zero-test { 140 | font-size: 0vw; 141 | } 142 | :where([data-apos-refreshable-body]) .zero-test, 143 | :where([data-apos-refreshable-body]).zero-test{ 144 | font-size: 0cqw; 145 | }`; 146 | 147 | await run(plugin, input, output, opts); 148 | }); 149 | 150 | it('should handle clamp with multiple arguments including nested calc', async () => { 151 | const input = ` 152 | .nested-calc { 153 | font-size: clamp(1rem, calc(50vw - 2rem), calc(100vh - 4rem)); 154 | }`; 155 | const output = ` 156 | .nested-calc { 157 | font-size: clamp(1rem, calc(50vw - 2rem), calc(100vh - 4rem)); 158 | } 159 | :where([data-apos-refreshable-body]) .nested-calc, 160 | :where([data-apos-refreshable-body]).nested-calc { 161 | font-size: clamp(1rem, calc(50cqw - 2rem), calc(100cqh - 4rem)); 162 | }`; 163 | 164 | await run(plugin, input, output, opts); 165 | }); 166 | }); 167 | 168 | // Fixed position tests 169 | describe('fixed position handling', () => { 170 | it('should convert fixed position elements to use container queries', async () => { 171 | const input = ` 172 | .fixed-header { 173 | position: fixed; 174 | top: 0; 175 | left: 0; 176 | width: 100vw; 177 | height: 60px; 178 | }`; 179 | const output = ` 180 | body[data-breakpoint-preview-mode] { 181 | position: relative; 182 | contain: layout; 183 | } 184 | .fixed-header { 185 | position: fixed; 186 | top: 0; 187 | left: 0; 188 | width: 100vw; 189 | height: 60px; 190 | } 191 | :where([data-apos-refreshable-body]) .fixed-header, 192 | :where([data-apos-refreshable-body]).fixed-header { 193 | position: sticky; 194 | --container-top: 0; 195 | top: var(--container-top); 196 | --container-left: 0; 197 | left: var(--container-left); 198 | width: 100cqw; 199 | height: 60px; 200 | }`; 201 | 202 | await run(plugin, input, output, opts); 203 | }); 204 | 205 | it('should handle fixed positioning within media queries', async () => { 206 | const input = ` 207 | @media (min-width: 768px) { 208 | .fixed-in-media { 209 | position: fixed; 210 | top: 0; 211 | width: 100vw; 212 | } 213 | }`; 214 | const output = ` 215 | body[data-breakpoint-preview-mode] { 216 | position: relative; 217 | contain: layout; 218 | } 219 | @media (min-width: 768px) { 220 | :where(body:not([data-breakpoint-preview-mode])) .fixed-in-media, 221 | :where(body:not([data-breakpoint-preview-mode])).fixed-in-media { 222 | position: fixed; 223 | top: 0; 224 | width: 100vw; 225 | } 226 | } 227 | @container (min-width: 768px) { 228 | .fixed-in-media { 229 | position: sticky; 230 | --container-top: 0; 231 | top: var(--container-top); 232 | width: 100cqw; 233 | } 234 | }`; 235 | 236 | await run(plugin, input, output, opts); 237 | }); 238 | }); 239 | 240 | // Dynamic viewport units 241 | describe('dynamic viewport units', () => { 242 | it('should convert dynamic viewport units to container units', async () => { 243 | const input = ` 244 | .dynamic { 245 | height: 100dvh; 246 | width: 100dvw; 247 | min-height: 100svh; 248 | max-width: 100lvw; 249 | }`; 250 | const output = ` 251 | .dynamic { 252 | height: 100dvh; 253 | width: 100dvw; 254 | min-height: 100svh; 255 | max-width: 100lvw; 256 | } 257 | :where([data-apos-refreshable-body]) .dynamic, 258 | :where([data-apos-refreshable-body]).dynamic { 259 | height: 100cqh; 260 | width: 100cqw; 261 | min-height: 100cqh; 262 | max-width: 100cqw; 263 | }`; 264 | 265 | await run(plugin, input, output, opts); 266 | }); 267 | 268 | it('should handle complex calc expressions with multiple viewport units', async () => { 269 | const input = ` 270 | .complex { 271 | margin: calc(10px + 2vw - 1vh); 272 | padding: calc((100vw - 20px) / 2 + 1vmin); 273 | }`; 274 | const output = ` 275 | .complex { 276 | margin: calc(10px + 2vw - 1vh); 277 | padding: calc((100vw - 20px) / 2 + 1vmin); 278 | } 279 | :where([data-apos-refreshable-body]) .complex, 280 | :where([data-apos-refreshable-body]).complex { 281 | margin: calc(10px + 2cqw - 1cqh); 282 | padding: calc((100cqw - 20px) / 2 + 1cqmin); 283 | }`; 284 | 285 | await run(plugin, input, output, opts); 286 | }); 287 | }); 288 | 289 | // Simple media queries 290 | describe('simple media query conversions', () => { 291 | it('should handle `<=` operator media queries', async () => { 292 | const input = ` 293 | @media (width <= 1024px) { 294 | .single-operator { 295 | width: 100vw; 296 | } 297 | }`; 298 | const output = ` 299 | @media (width <= 1024px) { 300 | :where(body:not([data-breakpoint-preview-mode])) .single-operator, 301 | :where(body:not([data-breakpoint-preview-mode])).single-operator { 302 | width: 100vw; 303 | } 304 | } 305 | @container (max-width: 1024px) { 306 | .single-operator { 307 | width: 100cqw; 308 | } 309 | }`; 310 | 311 | await run(plugin, input, output, opts); 312 | }); 313 | 314 | it('should handle `>=` operator media queries', async () => { 315 | const input = ` 316 | @media (width >= 240px) { 317 | .single-operator { 318 | width: 100vw; 319 | } 320 | }`; 321 | const output = ` 322 | @media (width >= 240px) { 323 | :where(body:not([data-breakpoint-preview-mode])) .single-operator, 324 | :where(body:not([data-breakpoint-preview-mode])).single-operator { 325 | width: 100vw; 326 | } 327 | } 328 | @container (min-width: 240px) { 329 | .single-operator { 330 | width: 100cqw; 331 | } 332 | }`; 333 | 334 | await run(plugin, input, output, opts); 335 | }); 336 | 337 | it('should handle poorly formatted media queries', async () => { 338 | const input = ` 339 | @media (width<=1024px) { 340 | .poorly-formatted { 341 | width: 100vw; 342 | } 343 | }`; 344 | const output = ` 345 | @media (width<=1024px) { 346 | :where(body:not([data-breakpoint-preview-mode])) .poorly-formatted, 347 | :where(body:not([data-breakpoint-preview-mode])).poorly-formatted { 348 | width: 100vw; 349 | } 350 | } 351 | @container (max-width: 1024px) { 352 | .poorly-formatted { 353 | width: 100cqw; 354 | } 355 | }`; 356 | 357 | await run(plugin, input, output, opts); 358 | }); 359 | 360 | it('should handle media queries with combined range and logical operators', async () => { 361 | const input = ` 362 | @media (min-width: 500px) and (width<=1024px) { 363 | .combined-operator { 364 | width: 90vw; 365 | margin: 0 5vw; 366 | } 367 | }`; 368 | const output = ` 369 | @media (min-width: 500px) and (width<=1024px) { 370 | :where(body:not([data-breakpoint-preview-mode])) .combined-operator, 371 | :where(body:not([data-breakpoint-preview-mode])).combined-operator { 372 | width: 90vw; 373 | margin: 0 5vw; 374 | } 375 | } 376 | @container (min-width: 500px) and (max-width: 1024px) { 377 | .combined-operator { 378 | width: 90cqw; 379 | margin: 0 5cqw; 380 | } 381 | }`; 382 | 383 | await run(plugin, input, output, opts); 384 | }); 385 | }); 386 | 387 | // Complex media queries 388 | describe('complex media query conversions', () => { 389 | it('should handle range syntax in media queries', async () => { 390 | const input = ` 391 | @media (240px <= width <= 1024px) { 392 | .range { 393 | width: 90vw; 394 | margin: 0 5vw; 395 | } 396 | }`; 397 | const output = ` 398 | @media (240px <= width <= 1024px) { 399 | :where(body:not([data-breakpoint-preview-mode])) .range, 400 | :where(body:not([data-breakpoint-preview-mode])).range { 401 | width: 90vw; 402 | margin: 0 5vw; 403 | } 404 | } 405 | @container (min-width: 240px) and (max-width: 1024px) { 406 | .range { 407 | width: 90cqw; 408 | margin: 0 5cqw; 409 | } 410 | }`; 411 | 412 | await run(plugin, input, output, opts); 413 | }); 414 | 415 | it('should handle mixed media queries with screen and print', async () => { 416 | const input = ` 417 | @media screen and (min-width: 768px), print { 418 | .mixed { 419 | width: 80vw; 420 | } 421 | }`; 422 | const output = ` 423 | @media screen and (min-width: 768px), print { 424 | :where(body:not([data-breakpoint-preview-mode])) .mixed, 425 | :where(body:not([data-breakpoint-preview-mode])).mixed { 426 | width: 80vw; 427 | } 428 | } 429 | @container (min-width: 768px) { 430 | .mixed { 431 | width: 80cqw; 432 | } 433 | }`; 434 | 435 | await run(plugin, input, output, opts); 436 | }); 437 | 438 | it('should handle orientation in media queries', async () => { 439 | const input = ` 440 | @media (orientation: landscape) and (min-width: 768px) { 441 | .landscape { 442 | height: 100vh; 443 | width: 100vw; 444 | } 445 | }`; 446 | const output = ` 447 | @media (orientation: landscape) and (min-width: 768px) { 448 | :where(body:not([data-breakpoint-preview-mode])) .landscape, 449 | :where(body:not([data-breakpoint-preview-mode])).landscape { 450 | height: 100vh; 451 | width: 100vw; 452 | } 453 | } 454 | @container (orientation: landscape) and (min-width: 768px) { 455 | .landscape { 456 | height: 100cqh; 457 | width: 100cqw; 458 | } 459 | }`; 460 | 461 | await run(plugin, input, output, opts); 462 | }); 463 | 464 | it('should handle orientation alone in media queries', async () => { 465 | const input = ` 466 | @media (orientation: landscape) { 467 | .landscape { 468 | height: 100vh; 469 | width: 100vw; 470 | } 471 | }`; 472 | const output = ` 473 | @media (orientation: landscape) { 474 | :where(body:not([data-breakpoint-preview-mode])) .landscape, 475 | :where(body:not([data-breakpoint-preview-mode])).landscape { 476 | height: 100vh; 477 | width: 100vw; 478 | } 479 | } 480 | @container (orientation: landscape) { 481 | .landscape { 482 | height: 100cqh; 483 | width: 100cqw; 484 | } 485 | }`; 486 | 487 | await run(plugin, input, output, opts); 488 | }); 489 | 490 | it('should handle multiple consecutive media queries', async () => { 491 | const input = ` 492 | @media (min-width: 320px) { 493 | .mobile { width: 90vw; } 494 | } 495 | @media (min-width: 768px) { 496 | .tablet { width: 80vw; } 497 | }`; 498 | const output = ` 499 | @media (min-width: 320px) { 500 | :where(body:not([data-breakpoint-preview-mode])) .mobile, 501 | :where(body:not([data-breakpoint-preview-mode])).mobile { 502 | width: 90vw; 503 | } 504 | } 505 | @container (min-width: 320px) { 506 | .mobile { 507 | width: 90cqw; 508 | } 509 | } 510 | @media (min-width: 768px) { 511 | :where(body:not([data-breakpoint-preview-mode])) .tablet, 512 | :where(body:not([data-breakpoint-preview-mode])).tablet { 513 | width: 80vw; 514 | } 515 | } 516 | @container (min-width: 768px) { 517 | .tablet { 518 | width: 80cqw; 519 | } 520 | }`; 521 | 522 | await run(plugin, input, output, opts); 523 | }); 524 | }); 525 | 526 | // Nested media queries 527 | describe('nested media query handling', () => { 528 | it('should handle nested media queries correctly', async () => { 529 | const input = ` 530 | @media screen { 531 | @media (min-width: 768px) { 532 | .nested { 533 | width: 80vw; 534 | } 535 | } 536 | }`; 537 | const output = ` 538 | @media screen { 539 | @media (min-width: 768px) { 540 | :where(body:not([data-breakpoint-preview-mode])) .nested, 541 | :where(body:not([data-breakpoint-preview-mode])).nested { 542 | width: 80vw; 543 | } 544 | } 545 | } 546 | @container (min-width: 768px) { 547 | .nested { 548 | width: 80cqw; 549 | } 550 | }`; 551 | 552 | await run(plugin, input, output, opts); 553 | }); 554 | 555 | it('should convert nested media queries to container queries (Tailwind)', async () => { 556 | const input = ` 557 | .sm\\:text-lg { 558 | @media (width >= 40rem) { 559 | font-size: var(--text-lg); 560 | line-height: 1.5; 561 | } 562 | } 563 | `; 564 | 565 | const output = ` 566 | :where(body:not([data-breakpoint-preview-mode])) .sm\\:text-lg, 567 | :where(body:not([data-breakpoint-preview-mode])).sm\\:text-lg { 568 | @media (width >= 40rem) { 569 | font-size: var(--text-lg); 570 | line-height: 1.5; 571 | } 572 | } 573 | .sm\\:text-lg { 574 | @container (min-width: 40rem) { 575 | font-size: var(--text-lg); 576 | line-height: 1.5; 577 | } 578 | } 579 | `; 580 | 581 | await run(plugin, input, output, opts); 582 | }); 583 | 584 | it('should handle multiple nested breakpoints', async () => { 585 | const input = ` 586 | .responsive { 587 | @media (width >= 640px) { 588 | padding: 2rem; 589 | } 590 | @media (width >= 1024px) { 591 | padding: 4rem; 592 | } 593 | } 594 | `; 595 | 596 | const output = ` 597 | :where(body:not([data-breakpoint-preview-mode])) .responsive, 598 | :where(body:not([data-breakpoint-preview-mode])).responsive { 599 | @media (width >= 640px) { 600 | padding: 2rem; 601 | } 602 | @media (width >= 1024px) { 603 | padding: 4rem; 604 | } 605 | } 606 | .responsive { 607 | @container (min-width: 640px) { 608 | padding: 2rem; 609 | } 610 | @container (min-width: 1024px) { 611 | padding: 4rem; 612 | } 613 | } 614 | `; 615 | 616 | await run(plugin, input, output, opts); 617 | }); 618 | 619 | describe('deep nesting with media queries', () => { 620 | it('should apply body check at root level for two-level nested media queries', async () => { 621 | const input = ` 622 | .parent { 623 | .child { 624 | @media (min-width: 768px) { 625 | padding: 2rem; 626 | } 627 | } 628 | }`; 629 | 630 | const output = ` 631 | :where(body:not([data-breakpoint-preview-mode])) .parent, 632 | :where(body:not([data-breakpoint-preview-mode])).parent { 633 | .child { 634 | @media (min-width: 768px) { 635 | padding: 2rem; 636 | } 637 | } 638 | } 639 | .parent { 640 | .child { 641 | @container (min-width: 768px) { 642 | padding: 2rem; 643 | } 644 | } 645 | }`; 646 | 647 | await run(plugin, input, output, opts); 648 | }); 649 | }); 650 | 651 | it('should apply body check at root level for three-level nested media queries', async () => { 652 | const input = ` 653 | .foo { 654 | .bar { 655 | .inside { 656 | @media screen and (max-width: 500px) { 657 | margin: 2rem; 658 | } 659 | } 660 | } 661 | }`; 662 | 663 | const output = ` 664 | :where(body:not([data-breakpoint-preview-mode])) .foo, 665 | :where(body:not([data-breakpoint-preview-mode])).foo { 666 | .bar { 667 | .inside { 668 | @media screen and (max-width: 500px) { 669 | margin: 2rem; 670 | } 671 | } 672 | } 673 | } 674 | .foo { 675 | .bar { 676 | .inside { 677 | @container (max-width: 500px) { 678 | margin: 2rem; 679 | } 680 | } 681 | } 682 | }`; 683 | 684 | await run(plugin, input, output, opts); 685 | }); 686 | 687 | it('should handle deeply nested media queries', async () => { 688 | const input = ` 689 | .foo { 690 | .bar { 691 | .inside { 692 | @media screen and (max-width: 500px) { 693 | margin: 2rem; 694 | } 695 | 696 | color: purple; 697 | } 698 | @media (width > 800px) { 699 | top: 5rem; 700 | } 701 | } 702 | }`; 703 | 704 | const output = ` 705 | :where(body:not([data-breakpoint-preview-mode])) .foo, 706 | :where(body:not([data-breakpoint-preview-mode])).foo { 707 | .bar { 708 | .inside { 709 | @media screen and (max-width: 500px) { 710 | margin: 2rem; 711 | } 712 | 713 | color: purple; 714 | } 715 | @media (width > 800px) { 716 | top: 5rem; 717 | } 718 | @media (width > 800px) { 719 | top: 5rem; 720 | } 721 | } 722 | } 723 | .foo { 724 | .bar { 725 | .inside { 726 | 727 | color: purple; 728 | 729 | @container (max-width: 500px) { 730 | margin: 2rem; 731 | } 732 | } 733 | @container (min-width: calc(800px + 0.02px)) { 734 | top: 5rem; 735 | } 736 | } 737 | }`; 738 | await run(plugin, input, output, opts); 739 | }); 740 | }); 741 | 742 | // Print-only queries 743 | describe('print-only media queries', () => { 744 | it('should skip print-only queries', async () => { 745 | const input = ` 746 | @media print { 747 | .print-only { 748 | width: 50vw; 749 | } 750 | }`; 751 | // Should remain unchanged 752 | const output = ` 753 | @media print { 754 | .print-only { 755 | width: 50vw; 756 | } 757 | }`; 758 | 759 | await run(plugin, input, output, opts); 760 | }); 761 | }); 762 | 763 | // Custom transform function 764 | describe('custom transform function', () => { 765 | it('should allow modifying media query params', async () => { 766 | const customTransform = (params) => { 767 | // Example: forcibly append "(orientation: landscape)" to any media query 768 | return `${params} and (orientation: landscape)`; 769 | }; 770 | 771 | const input = ` 772 | @media (min-width: 500px) { 773 | .transformed { 774 | width: 50vw; 775 | } 776 | }`; 777 | const output = ` 778 | @media (min-width: 500px) { 779 | :where(body:not([data-breakpoint-preview-mode])) .transformed, 780 | :where(body:not([data-breakpoint-preview-mode])).transformed { 781 | width: 50vw; 782 | } 783 | } 784 | @container (min-width: 500px) and (orientation: landscape) { 785 | .transformed { 786 | width: 50cqw; 787 | } 788 | }`; 789 | 790 | await run(plugin, input, output, { 791 | ...opts, 792 | transform: customTransform 793 | }); 794 | }); 795 | }); 796 | 797 | describe('Body level style to container compatibility', () => { 798 | it('should conserve body level styles when breakpoint preview is off (in media queries)', async () => { 799 | const input = ` 800 | @media (min-width: 768px) { 801 | body { 802 | font-size: 14px; 803 | } 804 | .toto, html body { 805 | font-size: 16px; 806 | } 807 | html.toto body.my-body { 808 | font-size: 16px; 809 | } 810 | html#foo body.my-body { 811 | font-size: 16px; 812 | } 813 | html>body#my-body.my-body { 814 | font-size: 16px; 815 | } 816 | #my-body { 817 | font-size: 16px; 818 | } 819 | .my-body { 820 | font-size: 16px; 821 | } 822 | body.my-body p { 823 | color: green; 824 | } 825 | body#my-body.my-body p { 826 | color: green; 827 | } 828 | #my-body p { 829 | color: purple; 830 | } 831 | .my-body p { 832 | color: purple; 833 | } 834 | } 835 | `; 836 | 837 | const output = ` 838 | @media (min-width: 768px) { 839 | body:not([data-breakpoint-preview-mode]) { 840 | font-size: 14px; 841 | } 842 | :where(body:not([data-breakpoint-preview-mode])) .toto, 843 | :where(body:not([data-breakpoint-preview-mode])).toto, 844 | body:not([data-breakpoint-preview-mode]) { 845 | font-size: 16px; 846 | } 847 | body:not([data-breakpoint-preview-mode]).my-body { 848 | font-size: 16px; 849 | } 850 | body:not([data-breakpoint-preview-mode]).my-body { 851 | font-size: 16px; 852 | } 853 | body:not([data-breakpoint-preview-mode])#my-body.my-body { 854 | font-size: 16px; 855 | } 856 | :where(body:not([data-breakpoint-preview-mode])) #my-body, 857 | :where(body:not([data-breakpoint-preview-mode]))#my-body { 858 | font-size: 16px; 859 | } 860 | :where(body:not([data-breakpoint-preview-mode])) .my-body, 861 | :where(body:not([data-breakpoint-preview-mode])).my-body { 862 | font-size: 16px; 863 | } 864 | body:not([data-breakpoint-preview-mode]).my-body p { 865 | color: green; 866 | } 867 | body:not([data-breakpoint-preview-mode])#my-body.my-body p { 868 | color: green; 869 | } 870 | :where(body:not([data-breakpoint-preview-mode])) #my-body p, 871 | :where(body:not([data-breakpoint-preview-mode]))#my-body p { 872 | color: purple; 873 | } 874 | :where(body:not([data-breakpoint-preview-mode])) .my-body p, 875 | :where(body:not([data-breakpoint-preview-mode])).my-body p { 876 | color: purple; 877 | } 878 | } 879 | @container (min-width: 768px) { 880 | [data-apos-refreshable-body] { 881 | font-size: 14px; 882 | } 883 | .toto, 884 | [data-apos-refreshable-body] { 885 | font-size: 16px; 886 | } 887 | [data-apos-refreshable-body].my-body { 888 | font-size: 16px; 889 | } 890 | [data-apos-refreshable-body].my-body { 891 | font-size: 16px; 892 | } 893 | [data-apos-refreshable-body]#my-body.my-body { 894 | font-size: 16px; 895 | } 896 | #my-body { 897 | font-size: 16px; 898 | } 899 | .my-body { 900 | font-size: 16px; 901 | } 902 | [data-apos-refreshable-body].my-body p { 903 | color: green; 904 | } 905 | [data-apos-refreshable-body]#my-body.my-body p { 906 | color: green; 907 | } 908 | #my-body p { 909 | color: purple; 910 | } 911 | .my-body p { 912 | color: purple; 913 | } 914 | }`; 915 | 916 | await run(plugin, input, output, opts); 917 | }); 918 | 919 | it('should move style from body to container fake body out of media queries', async () => { 920 | const input = ` 921 | .toto div { 922 | font-size: 16px; 923 | } 924 | .toto, body { 925 | background-color: green; 926 | } 927 | body.my-body .container { 928 | width: 50vw; 929 | } 930 | .my-body .container p { 931 | width: 50vw; 932 | } 933 | .toto { 934 | font-size: 16px; 935 | width: 50vw; 936 | } 937 | `; 938 | 939 | const output = ` 940 | .toto div { 941 | font-size: 16px; 942 | } 943 | .toto, 944 | body:not([data-breakpoint-preview-mode]), 945 | [data-apos-refreshable-body] { 946 | background-color: green; 947 | } 948 | body:not([data-breakpoint-preview-mode]).my-body .container { 949 | width: 50vw; 950 | } 951 | [data-apos-refreshable-body].my-body .container { 952 | width: 50cqw; 953 | } 954 | .my-body .container p { 955 | width: 50vw; 956 | } 957 | :where([data-apos-refreshable-body]) .my-body .container p, 958 | :where([data-apos-refreshable-body]).my-body .container p { 959 | width: 50cqw; 960 | } 961 | .toto { 962 | font-size: 16px; 963 | width: 50vw; 964 | } 965 | :where([data-apos-refreshable-body]) .toto, 966 | :where([data-apos-refreshable-body]).toto { 967 | font-size: 16px; 968 | width: 50cqw; 969 | } 970 | `; 971 | 972 | await run(plugin, input, output, opts); 973 | }); 974 | 975 | it('should transform body selectors to work with and without mobile preview without specific units', async () => { 976 | const input = ` 977 | body { 978 | color: purple; 979 | } 980 | .foo .bar, body .apos-area p { 981 | color: lightblue; 982 | } 983 | html body.my-body { 984 | background-color: red; 985 | } 986 | html>body#my-body.my-body { 987 | color: green; 988 | } 989 | html.toto#tutu > body#foo.bar { 990 | color: green; 991 | } 992 | `; 993 | const output = ` 994 | body:not([data-breakpoint-preview-mode]), 995 | [data-apos-refreshable-body] { 996 | color: purple; 997 | } 998 | .foo .bar, 999 | body:not([data-breakpoint-preview-mode]) .apos-area p, 1000 | [data-apos-refreshable-body] .apos-area p { 1001 | color: lightblue; 1002 | } 1003 | body:not([data-breakpoint-preview-mode]).my-body, 1004 | [data-apos-refreshable-body].my-body { 1005 | background-color: red; 1006 | } 1007 | body:not([data-breakpoint-preview-mode])#my-body.my-body, 1008 | [data-apos-refreshable-body]#my-body.my-body { 1009 | color: green; 1010 | } 1011 | body:not([data-breakpoint-preview-mode])#foo.bar, 1012 | [data-apos-refreshable-body]#foo.bar { 1013 | color: green; 1014 | } 1015 | `; 1016 | 1017 | await run(plugin, input, output, opts); 1018 | }); 1019 | }); 1020 | 1021 | // Debug mode 1022 | describe('debug mode', () => { 1023 | it('should not affect output when debug is enabled', async () => { 1024 | const debugOpts = { 1025 | ...opts, 1026 | debug: true 1027 | }; 1028 | const input = '.debug { width: 100vw; }'; 1029 | const output = ` 1030 | .debug { width: 100vw; } 1031 | :where([data-apos-refreshable-body]) .debug, 1032 | :where([data-apos-refreshable-body]).debug { width: 100cqw; }`; 1033 | 1034 | await run(plugin, input, output, debugOpts); 1035 | }); 1036 | }); 1037 | }); 1038 | --------------------------------------------------------------------------------