Skip to content

Commit 854a8bb

Browse files
committed
Create initial script
1 parent 7699354 commit 854a8bb

File tree

5 files changed

+454
-0
lines changed

5 files changed

+454
-0
lines changed

src/constants.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export const UNCSS_TEMP_DIR = './uncss-stats';
2+
export const TWIG_BASE_DIR = './templates';
3+
export const TWIG_PATTERN = /\.twig$/;
4+
export const VUE_BASE_DIR = './assets/js';
5+
export const VUE_PATTERN = /\.vue$/;
6+
export const CSS_BASE_DIR = './public/assets';
7+
export const CSS_PATTERN = /\.css$/;
8+
export const IGNORED_CLASS_PATTERNS: RegExp[] = [
9+
/^leaflet-/,
10+
/^lvml/,
11+
/^ymaps-/,
12+
/^svg-/,
13+
/^glide__/,
14+
/^glide--/,
15+
/^icon-/,
16+
/^js-/,
17+
];
18+
export const CLASSES_FROM_CSS_FILE_NAME = 'all_classes_from_css.json';
19+
export const CLASSES_FROM_CSS_FLATTENED_FILE_NAME = 'all_classes_from_css_flattened.json';
20+
export const CLASSES_FROM_TEMPLATES_FILE_NAME = 'all_classes_from_vue_and_twig.json';
21+
export const CLASSES_FROM_TEMPLATES_FLATTENED_FILE_NAME = 'all_classes_from_vue_and_twig_flattened.json';
22+
export const UNUSED_CSS_CLASSES_REPORT_FILE_NAME = 'unused_css_classes_report.json';
23+
export const IS_DEBUG = false;

src/extractors.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { isValidClassName } from './utils';
2+
3+
/**
4+
* Extracts CSS classes from a given content string, handling Twig and Vue syntax
5+
*
6+
* @param content - The content to extract CSS classes from
7+
* @returns An array of CSS classes
8+
*/
9+
export function extractClassesFromTemplate(content: string): string[] {
10+
const classPattern = /(?<=^|\s)class\s*=\s*(["'])((?:(?!\1).|\n)*)\1/g;
11+
const dynamicClassPattern = /(?<=^|\s):class\s*=\s*(['"])((?:(?!\1).|\n)*)\1/g;
12+
const classes = new Set<string>();
13+
let match: RegExpExecArray | null;
14+
15+
// For class=""
16+
while ((match = classPattern.exec(content)) !== null) {
17+
let classString = match[2];
18+
19+
// Extract classes from Twig constructs
20+
classString = classString.replace(/{%[\s\S]*?%}/g, (twigConstruct) => {
21+
const innerClasses = twigConstruct.match(/['"]([^'"]+)['"]/g) || [];
22+
innerClasses.forEach((cls) => {
23+
cls.replace(/['"]/g, '').split(/\s+/).forEach((c) => classes.add(c));
24+
});
25+
return ' ';
26+
});
27+
28+
// Extract potential classes from Vue/Twig interpolations
29+
classString = classString.replace(/{{[\s\S]*?}}/g, (interpolation) => {
30+
// Handle ternary operators
31+
const ternaryMatch = interpolation.match(/\?[^:]+:/) || [];
32+
if (ternaryMatch.length > 0) {
33+
const [truthy, falsy] = interpolation.split(':').map((part) => (part.match(/['"]([^'"]+)['"]/g) || [])
34+
.map((cls) => cls.replace(/['"]/g, ''))
35+
.join(' '));
36+
return `${truthy} ${falsy}`;
37+
}
38+
39+
// Handle non-ternary cases
40+
const potentialClasses = interpolation.match(/['"]([^'"]+)['"]/g) || [];
41+
return potentialClasses.map((cls) => cls.replace(/['"]/g, '')).join(' ');
42+
});
43+
44+
// Remove square brackets content
45+
classString = classString.replace(/\[[\s\S]*?\]/g, ' ');
46+
47+
// Split remaining classes and add to set
48+
classString.split(/\s+/).forEach((cls) => {
49+
if (cls.trim()) {
50+
classes.add(cls.trim());
51+
}
52+
});
53+
}
54+
55+
// For :class=""
56+
while ((match = dynamicClassPattern.exec(content)) !== null) {
57+
const classBinding = match[2];
58+
59+
if (classBinding.startsWith('{') && classBinding.endsWith('}')) {
60+
// Object syntax: { key: value, key2: value2 }
61+
const classObject = classBinding.slice(1, -1).trim();
62+
const keyValuePairs = classObject.split(',');
63+
keyValuePairs.forEach((pair) => {
64+
const key = pair.split(':')[0].trim();
65+
if (key && !key.startsWith('[')) {
66+
classes.add(key.replace(/['":]/g, ''));
67+
}
68+
});
69+
} else if (classBinding.startsWith('[') && classBinding.endsWith(']')) {
70+
// Array syntax: ['class1', 'class2', { key: value }]
71+
const classArray = classBinding.slice(1, -1).split(/,(?![^{]*})/);
72+
classArray.forEach((item) => {
73+
item = item.trim();
74+
75+
if ((item.startsWith("'") && item.endsWith("'")) || (item.startsWith('"') && item.endsWith('"'))) {
76+
classes.add(item.slice(1, -1));
77+
} else if (item.startsWith('{')) {
78+
const objectClasses = item.match(/'([^']+)'/g);
79+
if (objectClasses) {
80+
objectClasses.forEach((cls) => classes.add(cls.slice(1, -1)));
81+
}
82+
}
83+
});
84+
} else {
85+
// Simple binding or complex expression
86+
const possibleClasses = classBinding.match(/['"]([^'"]+)['"]/g);
87+
if (possibleClasses) {
88+
possibleClasses.forEach((cls) => {
89+
classes.add(cls.replace(/['"]/g, '').trim());
90+
});
91+
}
92+
}
93+
}
94+
95+
return Array.from(classes);
96+
}
97+
98+
interface ExtractOptions {
99+
extractOnly?: 'classes' | 'selectors';
100+
}
101+
102+
/**
103+
* Extracts CSS selectors or classes from a given content string
104+
*
105+
* @param content - The CSS content to extract from
106+
* @param options - Extraction options
107+
* @returns An array of CSS selectors or classes
108+
* @throws {Error} If an invalid extractOnly option is provided
109+
*/
110+
export function extractClassesFromCss(content: string, { extractOnly = 'classes' }: ExtractOptions = {}): string[] {
111+
if (extractOnly !== 'classes' && extractOnly !== 'selectors') {
112+
throw new Error("Invalid 'extractOnly' option. Must be either 'classes' or 'selectors'.");
113+
}
114+
115+
// Remove all background-image declarations
116+
content = content.replace(/background(-image)?:\s*url\s*\([^)]*\)[^;]*;/g, '');
117+
118+
// Remove all url() functions to catch any remaining cases
119+
content = content.replace(/url\s*\([^)]*\)/g, '');
120+
121+
let pattern: RegExp;
122+
if (extractOnly === 'classes') {
123+
// This pattern matches class names more accurately
124+
pattern = /\.(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)/g;
125+
} else {
126+
// This regex matches CSS selectors, including complex ones
127+
pattern = /([^{}]+)(?=\s*\{)/g;
128+
}
129+
130+
const items = new Set<string>();
131+
let match: RegExpExecArray | null;
132+
133+
while ((match = pattern.exec(content)) !== null) {
134+
if (extractOnly === 'classes') {
135+
// Remove the leading dot and filter out invalid class names
136+
const className = match[1];
137+
if (isValidClassName(className)) {
138+
items.add(className);
139+
}
140+
} else {
141+
// Split in case of multiple selectors separated by comma
142+
match[1].split(',').forEach((selector) => {
143+
const trimmedSelector = selector.trim();
144+
if (trimmedSelector) {
145+
items.add(trimmedSelector);
146+
}
147+
});
148+
}
149+
}
150+
151+
return Array.from(items);
152+
}

src/fileProcessors.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import { extractClassesFromTemplate, extractClassesFromCss } from './extractors';
4+
import { UNCSS_TEMP_DIR } from './constants';
5+
6+
interface FileInfo {
7+
name: string;
8+
path: string;
9+
}
10+
11+
interface ExtractedClasses {
12+
classes: string;
13+
path: string;
14+
}
15+
16+
interface ExtractedSelectors {
17+
selectors: string[];
18+
path: string;
19+
}
20+
21+
/**
22+
* Processes template files to extract CSS classes
23+
* @param templateFiles - Array of template file objects
24+
* @returns Array of objects containing extracted classes and file paths
25+
*/
26+
export function processTemplateFilesToExtractClasses(templateFiles: FileInfo[]): ExtractedClasses[] {
27+
return templateFiles.map((file) => {
28+
const content = fs.readFileSync(file.path, 'utf8');
29+
const classes = extractClassesFromTemplate(content);
30+
return {
31+
classes: classes.join(' '),
32+
path: file.path,
33+
};
34+
}).filter((item) => item.classes.length > 0);
35+
}
36+
37+
/**
38+
* Processes CSS files to extract classes
39+
* @param cssFiles - Array of CSS file objects
40+
* @returns Array of objects containing extracted selectors and file paths
41+
*/
42+
export function processCssFilesToExtractClasses(cssFiles: FileInfo[]): ExtractedSelectors[] {
43+
return cssFiles.map((file) => {
44+
const content = fs.readFileSync(file.path, 'utf8');
45+
try {
46+
const selectors = extractClassesFromCss(content, { extractOnly: 'classes' });
47+
return {
48+
selectors,
49+
path: file.path,
50+
};
51+
} catch (error) {
52+
console.error(`Error processing file ${file.path}: ${(error as Error).message}`);
53+
return null;
54+
}
55+
}).filter((item): item is ExtractedSelectors => item !== null && item.selectors.length > 0);
56+
}
57+
58+
/**
59+
* Writes extracted template classes to a file
60+
* @param templateClasses - Array of objects containing extracted classes and file paths
61+
* @param fileName - Name of the output file
62+
*/
63+
export function writeTemplateClassesToFile(templateClasses: ExtractedClasses[], fileName: string): void {
64+
const outputPath = path.join(UNCSS_TEMP_DIR, fileName);
65+
fs.writeFileSync(outputPath, JSON.stringify(templateClasses, null, 2));
66+
}
67+
68+
/**
69+
* Writes extracted CSS selectors to a file
70+
* @param cssSelectors - Array of objects containing extracted selectors and file paths
71+
* @param fileName - Name of the output file
72+
*/
73+
export function writeCssSelectorsToFile(cssSelectors: ExtractedSelectors[], fileName: string): void {
74+
const outputPath = path.join(UNCSS_TEMP_DIR, fileName);
75+
fs.writeFileSync(outputPath, JSON.stringify(cssSelectors, null, 2));
76+
}

0 commit comments

Comments
 (0)