diff --git a/lib/parsers.js b/lib/parsers.js index 8527e321..9f25fca5 100644 --- a/lib/parsers.js +++ b/lib/parsers.js @@ -10,12 +10,12 @@ const { asciiLowercase } = require("./utils/strings"); // CSS global keywords // @see https://drafts.csswg.org/css-cascade-5/#defaulting-keywords -const GLOBAL_KEY = Object.freeze(["initial", "inherit", "unset", "revert", "revert-layer"]); +const GLOBAL_KEYS = new Set(["initial", "inherit", "unset", "revert", "revert-layer"]); // System colors // @see https://drafts.csswg.org/css-color/#css-system-colors // @see https://drafts.csswg.org/css-color/#deprecated-system-colors -const SYS_COLOR = Object.freeze([ +const SYS_COLORS = new Set([ "accentcolor", "accentcolortext", "activeborder", @@ -60,6 +60,21 @@ const SYS_COLOR = Object.freeze([ "windowtext" ]); +// AST node types +// TODO: Export and use in properties/*.js in the future +const AST_TYPES = Object.freeze({ + CALC: "Calc", + DIMENSION: "Dimension", + FUNCTION: "Function", + GLOBAL_KEYWORD: "GlobalKeyword", + HASH: "Hash", + IDENTIFIER: "Identifier", + NUMBER: "Number", + PERCENTAGE: "Percentage", + STRING: "String", + URL: "Url" +}); + // Regular expressions const CALC_FUNC_NAMES = "(?:a?(?:cos|sin|tan)|abs|atan2|calc|clamp|exp|hypot|log|max|min|mod|pow|rem|round|sign|sqrt)"; @@ -72,8 +87,14 @@ const varContainedRegEx = /(?<=[*/\s(])var\(/; // Patched css-tree const cssTree = csstree.fork(syntaxes); -// Prepare stringified value. -exports.prepareValue = (value, globalObject = globalThis) => { +/** + * Prepares a stringified value. + * + * @param {string|number|null|undefined} value - The value to prepare. + * @param {object} [globalObject=globalThis] - The global object. + * @returns {string} The prepared value. + */ +const prepareValue = (value, globalObject = globalThis) => { // `null` is converted to an empty string. // @see https://webidl.spec.whatwg.org/#LegacyNullToEmptyString if (value === null) { @@ -99,29 +120,47 @@ exports.prepareValue = (value, globalObject = globalThis) => { } }; -// Value is a global keyword. -exports.isGlobalKeyword = (val) => { - return GLOBAL_KEY.includes(asciiLowercase(val)); +/** + * Checks if the value is a global keyword. + * + * @param {string} val - The value to check. + * @returns {boolean} True if the value is a global keyword, false otherwise. + */ +const isGlobalKeyword = (val) => { + return GLOBAL_KEYS.has(asciiLowercase(val)); }; -// Value starts with and/or contains CSS var() function. -exports.hasVarFunc = (val) => { +/** + * Checks if the value starts with or contains a CSS var() function. + * + * @param {string} val - The value to check. + * @returns {boolean} True if the value contains a var() function, false otherwise. + */ +const hasVarFunc = (val) => { return varRegEx.test(val) || varContainedRegEx.test(val); }; -// Value starts with and/or contains CSS calc() related functions. -exports.hasCalcFunc = (val) => { +/** + * Checks if the value starts with or contains CSS calc() or math functions. + * + * @param {string} val - The value to check. + * @returns {boolean} True if the value contains calc() or math functions, false otherwise. + */ +const hasCalcFunc = (val) => { return calcRegEx.test(val) || calcContainedRegEx.test(val); }; -// Splits value into an array. -// @see https://github.com/asamuzaK/cssColor/blob/main/src/js/util.ts -exports.splitValue = splitValue; - -// Parse CSS to AST. -exports.parseCSS = (val, opt, toObject = false) => { +/** + * Parses a CSS string into an AST. + * + * @param {string} val - The CSS string to parse. + * @param {object} opt - The options for parsing. + * @param {boolean} [toObject=false] - Whether to return a plain object. + * @returns {object} The AST or a plain object. + */ +const parseCSS = (val, opt, toObject = false) => { if (typeof val !== "string") { - val = exports.prepareValue(val); + val = prepareValue(val); } const ast = cssTree.parse(val, opt); if (toObject) { @@ -130,11 +169,17 @@ exports.parseCSS = (val, opt, toObject = false) => { return ast; }; -// Value is a valid property value. -// Returns `false` for custom property and/or var(). -exports.isValidPropertyValue = (prop, val) => { +/** + * Checks if the value is a valid property value. + * Returns false for custom properties or values containing var(). + * + * @param {string} prop - The property name. + * @param {string} val - The property value. + * @returns {boolean} True if the value is valid, false otherwise. + */ +const isValidPropertyValue = (prop, val) => { if (typeof val !== "string") { - val = exports.prepareValue(val); + val = prepareValue(val); } if (val === "") { return true; @@ -142,7 +187,7 @@ exports.isValidPropertyValue = (prop, val) => { // cssTree.lexer does not support deprecated system colors // @see https://github.com/w3c/webref/issues/1519#issuecomment-3120290261 // @see https://github.com/w3c/webref/issues/1647 - if (SYS_COLOR.includes(asciiLowercase(val))) { + if (SYS_COLORS.has(asciiLowercase(val))) { if (/^(?:-webkit-)?(?:[a-z][a-z\d]*-)*color$/i.test(prop)) { return true; } @@ -150,7 +195,7 @@ exports.isValidPropertyValue = (prop, val) => { } let ast; try { - ast = exports.parseCSS(val, { + ast = parseCSS(val, { context: "value" }); } catch { @@ -160,15 +205,21 @@ exports.isValidPropertyValue = (prop, val) => { return error === null && matched !== null; }; -// Simplify / resolve math functions. -exports.resolveCalc = (val, opt = { format: "specifiedValue" }) => { +/** + * Resolves CSS math functions. + * + * @param {string} val - The value to resolve. + * @param {object} [opt={ format: "specifiedValue" }] - The options for resolving. + * @returns {string|undefined} The resolved value. + */ +const resolveCalc = (val, opt = { format: "specifiedValue" }) => { if (typeof val !== "string") { - val = exports.prepareValue(val); + val = prepareValue(val); } - if (val === "" || exports.hasVarFunc(val) || !exports.hasCalcFunc(val)) { + if (val === "" || hasVarFunc(val) || !hasCalcFunc(val)) { return val; } - const obj = exports.parseCSS(val, { context: "value" }, true); + const obj = parseCSS(val, { context: "value" }, true); if (!obj?.children) { return; } @@ -176,7 +227,7 @@ exports.resolveCalc = (val, opt = { format: "specifiedValue" }) => { const values = []; for (const item of items) { const { type: itemType, name: itemName, value: itemValue } = item; - if (itemType === "Function") { + if (itemType === AST_TYPES.FUNCTION) { const value = cssTree .generate(item) .replace(/\)(?!\)|\s|,)/g, ") ") @@ -187,7 +238,7 @@ exports.resolveCalc = (val, opt = { format: "specifiedValue" }) => { } else { values.push(value); } - } else if (itemType === "String") { + } else if (itemType === AST_TYPES.STRING) { values.push(`"${itemValue}"`); } else { values.push(itemName ?? itemValue); @@ -196,14 +247,22 @@ exports.resolveCalc = (val, opt = { format: "specifiedValue" }) => { return values.join(" "); }; -// Parse property value. Returns string or array of parsed object. -exports.parsePropertyValue = (prop, val, opt = {}) => { +/** + * Parses a property value. + * Returns a string or an array of parsed objects. + * + * @param {string} prop - The property name. + * @param {string} val - The property value. + * @param {object} [opt={}] - The options for parsing. + * @returns {string|Array|undefined} The parsed value. + */ +const parsePropertyValue = (prop, val, opt = {}) => { const { caseSensitive, globalObject, inArray } = opt; - val = exports.prepareValue(val, globalObject); - if (val === "" || exports.hasVarFunc(val)) { + val = prepareValue(val, globalObject); + if (val === "" || hasVarFunc(val)) { return val; - } else if (exports.hasCalcFunc(val)) { - const calculatedValue = exports.resolveCalc(val, { + } else if (hasCalcFunc(val)) { + const calculatedValue = resolveCalc(val, { format: "specifiedValue" }); if (typeof calculatedValue !== "string") { @@ -212,22 +271,22 @@ exports.parsePropertyValue = (prop, val, opt = {}) => { val = calculatedValue; } const lowerCasedValue = asciiLowercase(val); - if (GLOBAL_KEY.includes(lowerCasedValue)) { + if (GLOBAL_KEYS.has(lowerCasedValue)) { if (inArray) { return [ { - type: "GlobalKeyword", + type: AST_TYPES.GLOBAL_KEYWORD, name: lowerCasedValue } ]; } return lowerCasedValue; - } else if (SYS_COLOR.includes(lowerCasedValue)) { + } else if (SYS_COLORS.has(lowerCasedValue)) { if (/^(?:(?:-webkit-)?(?:[a-z][a-z\d]*-)*color|border)$/i.test(prop)) { if (inArray) { return [ { - type: "Identifier", + type: AST_TYPES.IDENTIFIER, name: lowerCasedValue } ]; @@ -237,7 +296,7 @@ exports.parsePropertyValue = (prop, val, opt = {}) => { return; } try { - const ast = exports.parseCSS(val, { + const ast = parseCSS(val, { context: "value" }); const { error, matched } = cssTree.lexer.matchProperty(prop, ast); @@ -251,7 +310,7 @@ exports.parsePropertyValue = (prop, val, opt = {}) => { for (const item of items) { const { children, name, type, value, unit } = item; switch (type) { - case "Dimension": { + case AST_TYPES.DIMENSION: { parsedValues.push({ type, value, @@ -259,42 +318,40 @@ exports.parsePropertyValue = (prop, val, opt = {}) => { }); break; } - case "Function": { + case AST_TYPES.FUNCTION: { const css = cssTree .generate(item) .replace(/\)(?!\)|\s|,)/g, ") ") .trim(); const raw = items.length === 1 ? val : css; - const itemValue = raw - .replace(new RegExp(`^${name}\\(`), "") - .replace(/\)$/, "") - .trim(); + // Remove "${name}(" from the start and ")" from the end + const itemValue = raw.slice(name.length + 1, -1).trim(); if (name === "calc") { if (children.length === 1) { const [child] = children; - if (child.type === "Number") { + if (child.type === AST_TYPES.NUMBER) { parsedValues.push({ - type: "Calc", - name: "calc", + type: AST_TYPES.CALC, isNumber: true, value: `${parseFloat(child.value)}`, + name, raw }); } else { parsedValues.push({ - type: "Calc", - name: "calc", + type: AST_TYPES.CALC, isNumber: false, value: `${asciiLowercase(itemValue)}`, + name, raw }); } } else { parsedValues.push({ - type: "Calc", - name: "calc", + type: AST_TYPES.CALC, isNumber: false, value: asciiLowercase(itemValue), + name, raw }); } @@ -308,7 +365,7 @@ exports.parsePropertyValue = (prop, val, opt = {}) => { } break; } - case "Identifier": { + case AST_TYPES.IDENTIFIER: { if (caseSensitive) { parsedValues.push(item); } else { @@ -332,16 +389,24 @@ exports.parsePropertyValue = (prop, val, opt = {}) => { return val; }; -// Parse . -exports.parseNumber = (val, opt = {}) => { +/** + * Parses a numeric value (number, dimension, percentage). + * Helper function for parseNumber, parseLength, etc. + * + * @param {Array} val - The AST value. + * @param {object} [opt={}] - The options for parsing. + * @param {Function} validateType - Function to validate the node type. + * @returns {object|undefined} The parsed result containing num and unit, or undefined. + */ +const parseNumericValue = (val, opt, validateType) => { const [item] = val; - const { type, value } = item ?? {}; - if (type !== "Number") { + const { type, value, unit } = item ?? {}; + if (!validateType(type, value, unit)) { return; } - const { clamp } = opt; - const max = opt.max ?? Number.INFINITY; - const min = opt.min ?? Number.NEGATIVE_INFINITY; + const { clamp } = opt || {}; + const max = opt?.max ?? Number.INFINITY; + const min = opt?.min ?? Number.NEGATIVE_INFINITY; let num = parseFloat(value); if (clamp) { if (num > max) { @@ -352,140 +417,174 @@ exports.parseNumber = (val, opt = {}) => { } else if (num > max || num < min) { return; } - return `${num}`; + return { + num, + unit: unit ? asciiLowercase(unit) : null, + type + }; }; -// Parse . -exports.parseLength = (val, opt = {}) => { - const [item] = val; - const { type, value, unit } = item ?? {}; - if (type !== "Dimension" && !(type === "Number" && value === "0")) { +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @param {object} [opt={}] - The options for parsing. + * @returns {string|undefined} The parsed number. + */ +const parseNumber = (val, opt = {}) => { + const res = parseNumericValue(val, opt, (type) => type === AST_TYPES.NUMBER); + if (!res) { return; } - const { clamp } = opt; - const max = opt.max ?? Number.INFINITY; - const min = opt.min ?? Number.NEGATIVE_INFINITY; - let num = parseFloat(value); - if (clamp) { - if (num > max) { - num = max; - } else if (num < min) { - num = min; - } - } else if (num > max || num < min) { + return `${res.num}`; +}; + +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @param {object} [opt={}] - The options for parsing. + * @returns {string|undefined} The parsed length. + */ +const parseLength = (val, opt = {}) => { + const res = parseNumericValue( + val, + opt, + (type, value) => type === AST_TYPES.DIMENSION || (type === AST_TYPES.NUMBER && value === "0") + ); + if (!res) { return; } + const { num, unit } = res; if (num === 0 && !unit) { return `${num}px`; } else if (unit) { - return `${num}${asciiLowercase(unit)}`; + return `${num}${unit}`; } }; -// Parse . -exports.parsePercentage = (val, opt = {}) => { - const [item] = val; - const { type, value } = item ?? {}; - if (type !== "Percentage" && !(type === "Number" && value === "0")) { +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @param {object} [opt={}] - The options for parsing. + * @returns {string|undefined} The parsed percentage. + */ +const parsePercentage = (val, opt = {}) => { + const res = parseNumericValue( + val, + opt, + (type, value) => type === AST_TYPES.PERCENTAGE || (type === AST_TYPES.NUMBER && value === "0") + ); + if (!res) { return; } - const { clamp } = opt; - const max = opt.max ?? Number.INFINITY; - const min = opt.min ?? Number.NEGATIVE_INFINITY; - let num = parseFloat(value); - if (clamp) { - if (num > max) { - num = max; - } else if (num < min) { - num = min; - } - } else if (num > max || num < min) { - return; - } - if (num === 0) { - return `${num}%`; - } + const { num } = res; return `${num}%`; }; -// Parse . -exports.parseLengthPercentage = (val, opt = {}) => { - const [item] = val; - const { type, value, unit } = item ?? {}; - if (type !== "Dimension" && type !== "Percentage" && !(type === "Number" && value === "0")) { - return; - } - const { clamp } = opt; - const max = opt.max ?? Number.INFINITY; - const min = opt.min ?? Number.NEGATIVE_INFINITY; - let num = parseFloat(value); - if (clamp) { - if (num > max) { - num = max; - } else if (num < min) { - num = min; - } - } else if (num > max || num < min) { +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @param {object} [opt={}] - The options for parsing. + * @returns {string|undefined} The parsed length-percentage. + */ +const parseLengthPercentage = (val, opt = {}) => { + const res = parseNumericValue( + val, + opt, + (type, value) => + type === AST_TYPES.DIMENSION || + type === AST_TYPES.PERCENTAGE || + (type === AST_TYPES.NUMBER && value === "0") + ); + if (!res) { return; } + const { num, unit, type } = res; if (unit) { if (/deg|g?rad|turn/i.test(unit)) { return; } - return `${num}${asciiLowercase(unit)}`; - } else if (type === "Percentage") { + return `${num}${unit}`; + } else if (type === AST_TYPES.PERCENTAGE) { return `${num}%`; } else if (num === 0) { return `${num}px`; } }; -// Parse . -exports.parseAngle = (val) => { - const [item] = val; - const { type, value, unit } = item ?? {}; - if (type !== "Dimension" && !(type === "Number" && value === "0")) { +/** + * Parses an value. + * + * @param {Array} val - The AST value. + * @param {object} [opt={}] - The options for parsing. + * @returns {string|undefined} The parsed angle. + */ +const parseAngle = (val, opt = {}) => { + const res = parseNumericValue( + val, + opt, + (type, value) => type === AST_TYPES.DIMENSION || (type === AST_TYPES.NUMBER && value === "0") + ); + if (!res) { return; } - const num = parseFloat(value); + const { num, unit } = res; if (unit) { if (!/^(?:deg|g?rad|turn)$/i.test(unit)) { return; } - return `${num}${asciiLowercase(unit)}`; + return `${num}${unit}`; } else if (num === 0) { return `${num}deg`; } }; -// Parse . -exports.parseUrl = (val) => { +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @returns {string|undefined} The parsed url. + */ +const parseUrl = (val) => { const [item] = val; const { type, value } = item ?? {}; - if (type !== "Url") { + if (type !== AST_TYPES.URL) { return; } const str = value.replace(/\\\\/g, "\\").replaceAll('"', '\\"'); return `url("${str}")`; }; -// Parse . -exports.parseString = (val) => { +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @returns {string|undefined} The parsed string. + */ +const parseString = (val) => { const [item] = val; const { type, value } = item ?? {}; - if (type !== "String") { + if (type !== AST_TYPES.STRING) { return; } const str = value.replace(/\\\\/g, "\\").replaceAll('"', '\\"'); return `"${str}"`; }; -// Parse . -exports.parseColor = (val) => { +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @returns {string|undefined} The parsed color. + */ +const parseColor = (val) => { const [item] = val; const { name, type, value } = item ?? {}; switch (type) { - case "Function": { + case AST_TYPES.FUNCTION: { const res = resolveColor(`${name}(${value})`, { format: "specifiedValue" }); @@ -494,7 +593,7 @@ exports.parseColor = (val) => { } break; } - case "Hash": { + case AST_TYPES.HASH: { const res = resolveColor(`#${value}`, { format: "specifiedValue" }); @@ -503,8 +602,8 @@ exports.parseColor = (val) => { } break; } - case "Identifier": { - if (SYS_COLOR.includes(name)) { + case AST_TYPES.IDENTIFIER: { + if (SYS_COLORS.has(name)) { return name; } const res = resolveColor(name, { @@ -519,11 +618,16 @@ exports.parseColor = (val) => { } }; -// Parse . -exports.parseGradient = (val) => { +/** + * Parses a value. + * + * @param {Array} val - The AST value. + * @returns {string|undefined} The parsed gradient. + */ +const parseGradient = (val) => { const [item] = val; const { name, type, value } = item ?? {}; - if (type !== "Function") { + if (type !== AST_TYPES.FUNCTION) { return; } const res = resolveGradient(`${name}(${value})`, { @@ -533,3 +637,24 @@ exports.parseGradient = (val) => { return res; } }; + +module.exports = { + prepareValue, + isGlobalKeyword, + hasVarFunc, + hasCalcFunc, + splitValue, + parseCSS, + isValidPropertyValue, + resolveCalc, + parsePropertyValue, + parseNumber, + parseLength, + parsePercentage, + parseLengthPercentage, + parseAngle, + parseUrl, + parseString, + parseColor, + parseGradient +};