|
| 1 | +import { isValidClassName, removeBackgroundImages, removeUrlFunctions } from './utils'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Represents a set of CSS class names |
| 5 | + */ |
| 6 | +type ClassSet = Set<string>; |
| 7 | + |
| 8 | +/** |
| 9 | + * Options for class extraction |
| 10 | + */ |
| 11 | +interface ExtractOptions { |
| 12 | + /** |
| 13 | + * Specifies whether to extract classes or selectors |
| 14 | + * @default 'classes' |
| 15 | + */ |
| 16 | + extractOnly?: 'classes' | 'selectors'; |
| 17 | +} |
| 18 | + |
| 19 | +/** |
| 20 | + * Extracts CSS classes from a given content string, handling Twig and Vue syntax |
| 21 | + * |
| 22 | + * @param {string} content - The content to extract CSS classes from |
| 23 | + * @returns {string[]} An array of unique CSS classes |
| 24 | + */ |
| 25 | +export function extractClassesFromTemplate(content: string): string[] { |
| 26 | + const classes: ClassSet = new Set<string>(); |
| 27 | + |
| 28 | + extractStaticClasses(content, classes); |
| 29 | + extractDynamicClasses(content, classes); |
| 30 | + |
| 31 | + return Array.from(classes); |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * Extracts static CSS classes from the content |
| 36 | + * |
| 37 | + * @param {string} content - The content to extract from |
| 38 | + * @param {ClassSet} classes - The set to store extracted classes |
| 39 | + * @returns {void} |
| 40 | + */ |
| 41 | +function extractStaticClasses(content: string, classes: ClassSet): void { |
| 42 | + const classPattern = /(?<=^|\s)class\s*=\s*(["'])((?:(?!\1).|\n)*)\1/g; |
| 43 | + let match: RegExpExecArray | null; |
| 44 | + |
| 45 | + while ((match = classPattern.exec(content)) !== null) { |
| 46 | + let classString = match[2]; |
| 47 | + classString = processTwigConstructs(classString, classes); |
| 48 | + classString = processInterpolations(classString); |
| 49 | + classString = classString.replace(/\[[\s\S]*?\]/g, ' '); |
| 50 | + addClassesToSet(classString, classes); |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Extracts dynamic CSS classes from the content |
| 56 | + * |
| 57 | + * @param {string} content - The content to extract from |
| 58 | + * @param {ClassSet} classes - The set to store extracted classes |
| 59 | + * @returns {void} |
| 60 | + */ |
| 61 | +function extractDynamicClasses(content: string, classes: ClassSet): void { |
| 62 | + const dynamicClassPattern = /(?<=^|\s):class\s*=\s*(['"])((?:(?!\1).|\n)*)\1/g; |
| 63 | + let match: RegExpExecArray | null; |
| 64 | + |
| 65 | + while ((match = dynamicClassPattern.exec(content)) !== null) { |
| 66 | + const classBinding = match[2]; |
| 67 | + if (classBinding.startsWith('{') && classBinding.endsWith('}')) { |
| 68 | + processObjectSyntax(classBinding, classes); |
| 69 | + } else if (classBinding.startsWith('[') && classBinding.endsWith(']')) { |
| 70 | + processArraySyntax(classBinding, classes); |
| 71 | + } else { |
| 72 | + processSimpleBinding(classBinding, classes); |
| 73 | + } |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Extracts CSS selectors or classes from a given content string |
| 79 | + * |
| 80 | + * @param {string} content - The CSS content to extract from |
| 81 | + * @param {ExtractOptions} [options={ extractOnly: 'classes' }] - Extraction options |
| 82 | + * @returns {string[]} An array of CSS selectors or classes |
| 83 | + * @throws {Error} If an invalid extractOnly option is provided |
| 84 | + */ |
| 85 | +export function extractClassesFromCss(content: string, { extractOnly = 'classes' }: ExtractOptions = {}): string[] { |
| 86 | + validateExtractOption(extractOnly); |
| 87 | + content = removeBackgroundImages(content); |
| 88 | + content = removeUrlFunctions(content); |
| 89 | + |
| 90 | + const pattern = getExtractionPattern(extractOnly); |
| 91 | + const items: Set<string> = new Set<string>(); |
| 92 | + |
| 93 | + let match: RegExpExecArray | null; |
| 94 | + while ((match = pattern.exec(content)) !== null) { |
| 95 | + processMatch(match, extractOnly, items); |
| 96 | + } |
| 97 | + |
| 98 | + return Array.from(items); |
| 99 | +} |
| 100 | + |
| 101 | +/** |
| 102 | + * Validates the extractOnly option |
| 103 | + * |
| 104 | + * @param {string} extractOnly - The option to validate |
| 105 | + * @throws {Error} If the option is invalid |
| 106 | + * @returns {asserts extractOnly is 'classes' | 'selectors'} |
| 107 | + */ |
| 108 | +function validateExtractOption(extractOnly: string): asserts extractOnly is 'classes' | 'selectors' { |
| 109 | + if (extractOnly !== 'classes' && extractOnly !== 'selectors') { |
| 110 | + throw new Error("Invalid 'extractOnly' option. Must be either 'classes' or 'selectors'."); |
| 111 | + } |
| 112 | + |
| 113 | + if (extractOnly === 'selectors') { |
| 114 | + console.warn('Warning: Selector extraction may be incomplete or inaccurate. Some selectors might be identified, but full accuracy is not guaranteed.'); |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * Processes Twig constructs in a class string |
| 120 | + * |
| 121 | + * @param {string} classString - The class string to process |
| 122 | + * @param {ClassSet} classes - The set to store extracted classes |
| 123 | + * @returns {string} The processed class string |
| 124 | + */ |
| 125 | +function processTwigConstructs(classString: string, classes: ClassSet): string { |
| 126 | + return classString.replace(/{%[\s\S]*?%}/g, (twigConstruct) => { |
| 127 | + const innerClasses = twigConstruct.match(/['"]([^'"]+)['"]/g) || []; |
| 128 | + innerClasses.forEach((cls) => { |
| 129 | + cls.replace(/['"]/g, '').split(/\s+/).forEach((c) => classes.add(c)); |
| 130 | + }); |
| 131 | + return ' '; |
| 132 | + }); |
| 133 | +} |
| 134 | + |
| 135 | +/** |
| 136 | + * Processes interpolations in a class string |
| 137 | + * |
| 138 | + * @param {string} classString - The class string to process |
| 139 | + * @returns {string} The processed class string |
| 140 | + */ |
| 141 | +function processInterpolations(classString: string): string { |
| 142 | + return classString.replace(/{{[\s\S]*?}}/g, (interpolation) => { |
| 143 | + const ternaryMatch = interpolation.match(/\?[^:]+:/) || []; |
| 144 | + if (ternaryMatch.length > 0) { |
| 145 | + const [truthy, falsy] = interpolation.split(':').map((part) => (part.match(/['"]([^'"]+)['"]/g) || []) |
| 146 | + .map((cls) => cls.replace(/['"]/g, '')) |
| 147 | + .join(' ')); |
| 148 | + return `${truthy} ${falsy}`; |
| 149 | + } |
| 150 | + |
| 151 | + const potentialClasses = interpolation.match(/['"]([^'"]+)['"]/g) || []; |
| 152 | + return potentialClasses.map((cls) => cls.replace(/['"]/g, '')).join(' '); |
| 153 | + }); |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * Adds classes from a class string to a set |
| 158 | + * |
| 159 | + * @param {string} classString - The class string to process |
| 160 | + * @param {ClassSet} classes - The set to store extracted classes |
| 161 | + * @returns {void} |
| 162 | + */ |
| 163 | +function addClassesToSet(classString: string, classes: ClassSet): void { |
| 164 | + classString.split(/\s+/).forEach((cls) => { |
| 165 | + if (cls.trim()) { |
| 166 | + classes.add(cls.trim()); |
| 167 | + } |
| 168 | + }); |
| 169 | +} |
| 170 | + |
| 171 | +/** |
| 172 | + * Processes object syntax in a class binding |
| 173 | + * |
| 174 | + * @param {string} classBinding - The class binding to process |
| 175 | + * @param {ClassSet} classes - The set to store extracted classes |
| 176 | + * @returns {void} |
| 177 | + */ |
| 178 | +function processObjectSyntax(classBinding: string, classes: ClassSet): void { |
| 179 | + const classObject = classBinding.slice(1, -1).trim(); |
| 180 | + const keyValuePairs = classObject.split(','); |
| 181 | + keyValuePairs.forEach((pair) => { |
| 182 | + const key = pair.split(':')[0].trim(); |
| 183 | + if (key && !key.startsWith('[')) { |
| 184 | + classes.add(key.replace(/['":]/g, '')); |
| 185 | + } |
| 186 | + }); |
| 187 | +} |
| 188 | + |
| 189 | +/** |
| 190 | + * Processes array syntax in a class binding |
| 191 | + * |
| 192 | + * @param {string} classBinding - The class binding to process |
| 193 | + * @param {ClassSet} classes - The set to store extracted classes |
| 194 | + * @returns {void} |
| 195 | + */ |
| 196 | +function processArraySyntax(classBinding: string, classes: ClassSet): void { |
| 197 | + const classArray = classBinding.slice(1, -1).split(/,(?![^{]*})/); |
| 198 | + classArray.forEach((item) => { |
| 199 | + item = item.trim(); |
| 200 | + |
| 201 | + if ((item.startsWith("'") && item.endsWith("'")) || (item.startsWith('"') && item.endsWith('"'))) { |
| 202 | + classes.add(item.slice(1, -1)); |
| 203 | + } else if (item.startsWith('{')) { |
| 204 | + const objectClasses = item.match(/'([^']+)'/g); |
| 205 | + if (objectClasses) { |
| 206 | + objectClasses.forEach((cls) => classes.add(cls.slice(1, -1))); |
| 207 | + } |
| 208 | + } |
| 209 | + }); |
| 210 | +} |
| 211 | + |
| 212 | +/** |
| 213 | + * Processes a simple class binding |
| 214 | + * |
| 215 | + * @param {string} classBinding - The class binding to process |
| 216 | + * @param {ClassSet} classes - The set to store extracted classes |
| 217 | + * @returns {void} |
| 218 | + */ |
| 219 | +function processSimpleBinding(classBinding: string, classes: ClassSet): void { |
| 220 | + const possibleClasses = classBinding.match(/['"]([^'"]+)['"]/g); |
| 221 | + if (possibleClasses) { |
| 222 | + possibleClasses.forEach((cls) => { |
| 223 | + classes.add(cls.replace(/['"]/g, '').trim()); |
| 224 | + }); |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * Gets the extraction pattern based on the extraction type |
| 230 | + * |
| 231 | + * @param {'classes' | 'selectors'} extractOnly - The type of extraction |
| 232 | + * @returns {RegExp} The extraction pattern |
| 233 | + */ |
| 234 | +function getExtractionPattern(extractOnly: 'classes' | 'selectors'): RegExp { |
| 235 | + return extractOnly === 'classes' |
| 236 | + ? /\.(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)/g |
| 237 | + : /([^{}]+)(?=\s*\{)/g; |
| 238 | +} |
| 239 | + |
| 240 | +/** |
| 241 | + * Processes a regex match based on the extraction type |
| 242 | + * |
| 243 | + * @param {RegExpExecArray} match - The regex match result |
| 244 | + * @param {'classes' | 'selectors'} extractOnly - The type of extraction |
| 245 | + * @param {Set<string>} items - The set to store extracted items |
| 246 | + * @returns {void} |
| 247 | + */ |
| 248 | +function processMatch(match: RegExpExecArray, extractOnly: 'classes' | 'selectors', items: Set<string>): void { |
| 249 | + if (extractOnly === 'classes') { |
| 250 | + const className = match[1]; |
| 251 | + if (isValidClassName(className)) { |
| 252 | + items.add(className); |
| 253 | + } |
| 254 | + } else { |
| 255 | + match[1].split(',').forEach((selector) => { |
| 256 | + const trimmedSelector = selector.trim(); |
| 257 | + if (trimmedSelector) { |
| 258 | + items.add(trimmedSelector); |
| 259 | + } |
| 260 | + }); |
| 261 | + } |
| 262 | +} |
0 commit comments