Skip to content

Commit 489429f

Browse files
authored
Merge pull request #1 from yoktav/develop
v1.0.0
2 parents 6a5d125 + a9007d6 commit 489429f

12 files changed

+1241
-2
lines changed

.editorconfig

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# editorconfig.org
2+
root = true
3+
4+
[*]
5+
indent_style = space
6+
indent_size = 2
7+
end_of_line = lf
8+
charset = utf-8
9+
trim_trailing_whitespace = true
10+
insert_final_newline = true
11+
12+
[*.md]
13+
trim_trailing_whitespace = false

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
.DS_Store
3+
*.log
4+
dist/

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2024 Yilmaz
3+
Copyright (c) 2024 Yilmaz Oktav
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
# twig-unused-css-finder
1+
# twig-unused-css-finder
2+
3+
A tool to find unused CSS in Twig templates.
4+
5+
## Installation

package.json

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "twig-unused-css-finder",
3+
"version": "1.0.0",
4+
"description": "A tool to find unused CSS in Twig templates",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"scripts": {
8+
"build": "rm -rf dist && tsc --emitDeclarationOnly && rollup -c",
9+
"prepublishOnly": "npm run build",
10+
"start": "node dist/index.js",
11+
"dev": "rollup -c -w",
12+
"test": "echo \"Error: no test specified\" && exit 1"
13+
},
14+
"keywords": [
15+
"twig",
16+
"css",
17+
"unused",
18+
"finder"
19+
],
20+
"author": "Yilmaz Oktav",
21+
"license": "MIT",
22+
"repository": {
23+
"type": "git",
24+
"url": "https://github.com/yoktav/twig-unused-css-finder.git"
25+
},
26+
"engines": {
27+
"node": ">=14.0.0"
28+
},
29+
"devDependencies": {
30+
"@rollup/plugin-commonjs": "^22.0.0",
31+
"@rollup/plugin-node-resolve": "^13.3.0",
32+
"@rollup/plugin-terser": "^0.4.4",
33+
"@rollup/plugin-typescript": "^8.3.2",
34+
"@types/node": "^14.14.31",
35+
"rollup": "^2.75.0",
36+
"tslib": "^2.4.0",
37+
"typescript": "^4.2.2"
38+
},
39+
"dependencies": {},
40+
"files": [
41+
"dist"
42+
]
43+
}

rollup.config.js

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import typescript from '@rollup/plugin-typescript';
2+
import { nodeResolve } from '@rollup/plugin-node-resolve';
3+
import commonjs from '@rollup/plugin-commonjs';
4+
import terser from '@rollup/plugin-terser';
5+
6+
export default {
7+
input: 'src/index.ts',
8+
output: {
9+
file: 'dist/index.js',
10+
format: 'cjs',
11+
exports: 'named',
12+
sourcemap: true,
13+
},
14+
plugins: [
15+
typescript(),
16+
nodeResolve(),
17+
commonjs(),
18+
terser()
19+
],
20+
external: ['fs', 'path']
21+
};

src/extractors.ts

+262
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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

Comments
 (0)