@mozaic-ds/postcss-media-queries-packer-on-demand
Version:
PostCSS plugin that group media queries on demand.
253 lines (205 loc) • 6.74 kB
JavaScript
const postcss = require('postcss')
const list = require('postcss/lib/list')
let startPacking = false
let queries = {}
let queryLists = []
/* eslint-disable */
/**
* Parses a media query list string into an array of query objects.
*
* @param {string} queryList - The media query list string to parse.
* @returns {Array<Object>} An array of query objects, where each object represents a media query.
* Each query object contains features as keys and their corresponding values as arrays.
*/
const parseQueryList = (queryList) => {
const queries = []
list.comma(queryList).forEach((query) => {
const expressions = {}
list.space(query).forEach((expression) => {
let newExpression = expression.toLowerCase()
if (newExpression === 'and') {
return
}
if (/^\w+$/.test(newExpression)) {
expressions[newExpression] = true
return
}
newExpression = list.split(newExpression.replace(/^\(|\)$/g, ''), [':'])
const [feature, value] = newExpression
if (!expressions[feature]) {
expressions[feature] = []
}
expressions[feature].push(value)
})
queries.push(expressions)
})
return queries
}
/**
* Converts a CSS length value to a numeric value in pixels.
*
* @param {string} length - The CSS length value to inspect. Can be in units of 'ch', 'em', 'ex', 'px', or 'rem'.
* @returns {number} The numeric value in pixels. Returns 0 if the input is '0'. Returns Number.MAX_VALUE if the input is not a valid CSS length.
*/
const inspectLength = (length) => {
if (length === '0') {
return 0
}
const matches = /(-?\d*\.?\d+)(ch|em|ex|px|rem)/.exec(length)
if (!matches) {
return Number.MAX_VALUE
}
matches.shift()
const [num, unit] = matches
let newNum = num
switch (unit) {
case 'ch':
newNum = parseFloat(newNum) * 8.8984375
break
case 'em':
case 'rem':
newNum = parseFloat(newNum) * 16
break
case 'ex':
newNum = parseFloat(newNum) * 8.296875
break
case 'px':
newNum = parseFloat(newNum)
break
}
return newNum
}
/**
* Picks the minimum 'min-width' value from an array of media feature expressions.
*
* @param {Array<Object>} expressions - An array of media feature expressions.
* @param {Object} expressions[].min-width - The 'min-width' value of the media feature.
* @param {boolean} expressions[].not - Indicates if the media feature is negated.
* @param {boolean} expressions[].print - Indicates if the media feature is for print.
* @returns {number|null} The minimum 'min-width' value, or null if no valid 'min-width' is found.
*/
const pickMinimumMinWidth = (expressions) => {
const minWidths = []
expressions.forEach((feature) => {
let minWidth = feature['min-width']
if (!minWidth || feature.not || feature.print) {
minWidth = [null]
}
minWidths.push(minWidth.map(inspectLength).sort((a, b) => b - a)[0])
})
return minWidths.sort((a, b) => a - b)[0]
}
/**
* Sorts an array of media query lists based on the provided sorting criteria.
*
* @param {Array} queryLists - The array of media query lists to be sorted.
* @param {boolean|function} sort - The sorting criteria. If false, no sorting is applied.
* If a function is provided, it is used as the comparison function for sorting.
* @returns {Array} - The sorted array of media query lists.
*/
const sortQueryLists = (queryLists, sort) => {
const mapQueryLists = []
if (!sort) {
return queryLists
}
if (typeof sort === 'function') {
return queryLists.sort(sort)
}
queryLists.forEach((queryList) => {
mapQueryLists.push(parseQueryList(queryList))
})
return mapQueryLists
.map((e, i) => ({
index: i,
value: pickMinimumMinWidth(e),
}))
.sort((a, b) => a.value - b.value)
.map((e) => queryLists[e.index])
}
/**
* Adds a node to the appropriate at-rules based on its parent and parameters.
*
* This function processes a given node and organizes it into at-rules. If the node's
* parent is not the root, it creates a new at-rule with the parent's name and parameters,
* appends the node's rules to this new at-rule, and then replaces the original node with
* the new at-rule. It also manages a list of queries and clones nodes as needed.
*
* @param {Object} node - The node to be processed and added to at-rules.
* @param {Object} node.parent - The parent of the node.
* @param {string} node.parent.name - The name of the parent node.
* @param {string} node.parent.params - The parameters of the parent node.
* @param {string} node.params - The parameters of the node.
* @param {Function} node.each - Function to iterate over the node's rules.
* @param {Function} node.remove - Function to remove the node.
* @param {Function} node.removeAll - Function to remove all child nodes.
* @param {Function} node.append - Function to append a new rule to the node.
* @param {Function} node.clone - Function to clone the node.
*/
function addToAtRules(node) {
if (node.parent.parent && node.parent.parent.type !== 'root') {
return
}
if (node.parent.type !== 'root') {
const newAtRule = postcss.atRule({
name: node.parent.name,
params: node.parent.params,
})
node.each((rule) => {
newAtRule.append(rule)
})
node.remove()
node.removeAll()
node.append(newAtRule)
}
const queryList = node.params
const past = queries[queryList]
if (typeof past === 'object') {
node.each((rule) => {
past.append(rule.clone())
})
} else {
queries[queryList] = node.clone()
queryLists.push(queryList)
}
node.remove()
}
module.exports = (options = {}) => {
const opts = {
sort: false,
...options,
}
return {
postcssPlugin: 'postcss-media-queries-packer-on-demand',
Once(root) {
root.each((node) => {
if (node.type === 'comment' && node.text === 'mqp:start') {
startPacking = true
node.remove()
}
if (node.type === 'comment' && node.text === 'mqp:end') {
startPacking = false
}
if (
node.type === 'atrule' &&
node.name === 'media' &&
startPacking === true
) {
addToAtRules(node)
}
if (
node.type === 'comment' &&
node.text === 'mqp:end' &&
startPacking === false
) {
sortQueryLists(queryLists, opts.sort).forEach((queryList) => {
node.before(queries[queryList])
})
node.remove()
queries = {}
queryLists = []
}
})
},
}
}
module.exports.postcss = true