'use strict'; const valueParser = require('postcss-value-parser'); /* * Constants (parser usage) */ const SINGLE_QUOTE = "'".charCodeAt(0); const DOUBLE_QUOTE = '"'.charCodeAt(0); const BACKSLASH = '\\'.charCodeAt(0); const NEWLINE = '\n'.charCodeAt(0); const SPACE = ' '.charCodeAt(0); const FEED = '\f'.charCodeAt(0); const TAB = '\t'.charCodeAt(0); const CR = '\r'.charCodeAt(0); const WORD_END = /[ \n\t\r\f'"\\]/g; /* * Constants (node type strings) */ const C_STRING = 'string'; const C_ESCAPED_SINGLE_QUOTE = 'escapedSingleQuote'; const C_ESCAPED_DOUBLE_QUOTE = 'escapedDoubleQuote'; const C_SINGLE_QUOTE = 'singleQuote'; const C_DOUBLE_QUOTE = 'doubleQuote'; const C_NEWLINE = 'newline'; const C_SINGLE = 'single'; /* * Literals */ const L_SINGLE_QUOTE = `'`; const L_DOUBLE_QUOTE = `"`; const L_NEWLINE = `\\\n`; /* * Parser nodes */ const T_ESCAPED_SINGLE_QUOTE = { type: C_ESCAPED_SINGLE_QUOTE, value: `\\'` }; const T_ESCAPED_DOUBLE_QUOTE = { type: C_ESCAPED_DOUBLE_QUOTE, value: `\\"` }; const T_SINGLE_QUOTE = { type: C_SINGLE_QUOTE, value: L_SINGLE_QUOTE }; const T_DOUBLE_QUOTE = { type: C_DOUBLE_QUOTE, value: L_DOUBLE_QUOTE }; const T_NEWLINE = { type: C_NEWLINE, value: L_NEWLINE }; /** @typedef {T_ESCAPED_SINGLE_QUOTE | T_ESCAPED_DOUBLE_QUOTE | T_SINGLE_QUOTE | T_NEWLINE} StringAstNode */ /** * @typedef {{nodes: StringAstNode[], * types: {escapedSingleQuote: number, escapedDoubleQuote: number, singleQuote: number, doubleQuote: number}, * quotes: boolean}} StringAst */ /** * @param {StringAst} ast * @return {string} */ function stringify(ast) { return ast.nodes.reduce((str, { value }) => { // Collapse multiple line strings automatically if (value === L_NEWLINE) { return str; } return str + value; }, ''); } /** * @param {string} str * @return {StringAst} */ function parse(str) { let code, next, value; let pos = 0; let len = str.length; /** @type StringAst */ const ast = { nodes: [], types: { escapedSingleQuote: 0, escapedDoubleQuote: 0, singleQuote: 0, doubleQuote: 0, }, quotes: false, }; while (pos < len) { code = str.charCodeAt(pos); switch (code) { case SPACE: case TAB: case CR: case FEED: next = pos; do { next += 1; code = str.charCodeAt(next); } while ( code === SPACE || code === NEWLINE || code === TAB || code === CR || code === FEED ); ast.nodes.push({ type: 'space', value: str.slice(pos, next), }); pos = next - 1; break; case SINGLE_QUOTE: ast.nodes.push(T_SINGLE_QUOTE); ast.types[C_SINGLE_QUOTE]++; ast.quotes = true; break; case DOUBLE_QUOTE: ast.nodes.push(T_DOUBLE_QUOTE); ast.types[C_DOUBLE_QUOTE]++; ast.quotes = true; break; case BACKSLASH: next = pos + 1; if (str.charCodeAt(next) === SINGLE_QUOTE) { ast.nodes.push(T_ESCAPED_SINGLE_QUOTE); ast.types[C_ESCAPED_SINGLE_QUOTE]++; ast.quotes = true; pos = next; break; } else if (str.charCodeAt(next) === DOUBLE_QUOTE) { ast.nodes.push(T_ESCAPED_DOUBLE_QUOTE); ast.types[C_ESCAPED_DOUBLE_QUOTE]++; ast.quotes = true; pos = next; break; } else if (str.charCodeAt(next) === NEWLINE) { ast.nodes.push(T_NEWLINE); pos = next; break; } /* * We need to fall through here to handle the token as * a whole word. The missing 'break' is intentional. */ default: WORD_END.lastIndex = pos + 1; WORD_END.test(str); if (WORD_END.lastIndex === 0) { next = len - 1; } else { next = WORD_END.lastIndex - 2; } value = str.slice(pos, next + 1); ast.nodes.push({ type: C_STRING, value, }); pos = next; } pos++; } return ast; } /** * @param {valueParser.StringNode} node * @param {StringAst} ast * @return {void} */ function changeWrappingQuotes(node, ast) { const { types } = ast; if (types[C_SINGLE_QUOTE] || types[C_DOUBLE_QUOTE]) { return; } if ( node.quote === L_SINGLE_QUOTE && types[C_ESCAPED_SINGLE_QUOTE] > 0 && !types[C_ESCAPED_DOUBLE_QUOTE] ) { node.quote = L_DOUBLE_QUOTE; } if ( node.quote === L_DOUBLE_QUOTE && types[C_ESCAPED_DOUBLE_QUOTE] > 0 && !types[C_ESCAPED_SINGLE_QUOTE] ) { node.quote = L_SINGLE_QUOTE; } ast.nodes = changeChildQuotes(ast.nodes, node.quote); } /** * @param {StringAstNode[]} childNodes * @param {string} parentQuote * @return {StringAstNode[]} */ function changeChildQuotes(childNodes, parentQuote) { const updatedChildren = []; for (const child of childNodes) { if ( child.type === C_ESCAPED_DOUBLE_QUOTE && parentQuote === L_SINGLE_QUOTE ) { updatedChildren.push(T_DOUBLE_QUOTE); } else if ( child.type === C_ESCAPED_SINGLE_QUOTE && parentQuote === L_DOUBLE_QUOTE ) { updatedChildren.push(T_SINGLE_QUOTE); } else { updatedChildren.push(child); } } return updatedChildren; } /** * @param {string} value * @param {'single' | 'double'} preferredQuote * @return {string} */ function normalize(value, preferredQuote) { if (!value || !value.length) { return value; } return valueParser(value) .walk((child) => { if (child.type !== C_STRING) { return; } const ast = parse(child.value); if (ast.quotes) { changeWrappingQuotes(child, ast); } else if (preferredQuote === C_SINGLE) { child.quote = L_SINGLE_QUOTE; } else { child.quote = L_DOUBLE_QUOTE; } child.value = stringify(ast); }) .toString(); } /** * @param {string} original * @param {Map} cache * @param {'single' | 'double'} preferredQuote * @return {string} */ function minify(original, cache, preferredQuote) { const key = original + '|' + preferredQuote; if (cache.has(key)) { return /** @type {string} */ (cache.get(key)); } const newValue = normalize(original, preferredQuote); cache.set(key, newValue); return newValue; } /** @typedef {{preferredQuote?: 'double' | 'single'}} Options */ /** * @type {import('postcss').PluginCreator} * @param {Options} opts * @return {import('postcss').Plugin} */ function pluginCreator(opts) { const { preferredQuote } = Object.assign( {}, { preferredQuote: 'double', }, opts ); return { postcssPlugin: 'postcss-normalize-string', OnceExit(css) { const cache = new Map(); css.walk((node) => { switch (node.type) { case 'rule': node.selector = minify(node.selector, cache, preferredQuote); break; case 'decl': node.value = minify(node.value, cache, preferredQuote); break; case 'atrule': node.params = minify(node.params, cache, preferredQuote); break; } }); }, }; } pluginCreator.postcss = true; module.exports = pluginCreator;