Skip to content

Commit

Permalink
Merge pull request #1 from yoktav/develop
Browse files Browse the repository at this point in the history
v1.0.0
  • Loading branch information
yoktav authored Sep 17, 2024
2 parents 6a5d125 + a9007d6 commit 489429f
Show file tree
Hide file tree
Showing 12 changed files with 1,241 additions and 2 deletions.
13 changes: 13 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# editorconfig.org
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.DS_Store
*.log
dist/
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2024 Yilmaz
Copyright (c) 2024 Yilmaz Oktav

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# twig-unused-css-finder
# twig-unused-css-finder

A tool to find unused CSS in Twig templates.

## Installation
43 changes: 43 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "twig-unused-css-finder",
"version": "1.0.0",
"description": "A tool to find unused CSS in Twig templates",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && tsc --emitDeclarationOnly && rollup -c",
"prepublishOnly": "npm run build",
"start": "node dist/index.js",
"dev": "rollup -c -w",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"twig",
"css",
"unused",
"finder"
],
"author": "Yilmaz Oktav",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/yoktav/twig-unused-css-finder.git"
},
"engines": {
"node": ">=14.0.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^22.0.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^8.3.2",
"@types/node": "^14.14.31",
"rollup": "^2.75.0",
"tslib": "^2.4.0",
"typescript": "^4.2.2"
},
"dependencies": {},
"files": [
"dist"
]
}
21 changes: 21 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';

export default {
input: 'src/index.ts',
output: {
file: 'dist/index.js',
format: 'cjs',
exports: 'named',
sourcemap: true,
},
plugins: [
typescript(),
nodeResolve(),
commonjs(),
terser()
],
external: ['fs', 'path']
};
262 changes: 262 additions & 0 deletions src/extractors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { isValidClassName, removeBackgroundImages, removeUrlFunctions } from './utils';

/**
* Represents a set of CSS class names
*/
type ClassSet = Set<string>;

/**
* Options for class extraction
*/
interface ExtractOptions {
/**
* Specifies whether to extract classes or selectors
* @default 'classes'
*/
extractOnly?: 'classes' | 'selectors';
}

/**
* Extracts CSS classes from a given content string, handling Twig and Vue syntax
*
* @param {string} content - The content to extract CSS classes from
* @returns {string[]} An array of unique CSS classes
*/
export function extractClassesFromTemplate(content: string): string[] {
const classes: ClassSet = new Set<string>();

extractStaticClasses(content, classes);
extractDynamicClasses(content, classes);

return Array.from(classes);
}

/**
* Extracts static CSS classes from the content
*
* @param {string} content - The content to extract from
* @param {ClassSet} classes - The set to store extracted classes
* @returns {void}
*/
function extractStaticClasses(content: string, classes: ClassSet): void {
const classPattern = /(?<=^|\s)class\s*=\s*(["'])((?:(?!\1).|\n)*)\1/g;
let match: RegExpExecArray | null;

while ((match = classPattern.exec(content)) !== null) {
let classString = match[2];
classString = processTwigConstructs(classString, classes);
classString = processInterpolations(classString);
classString = classString.replace(/\[[\s\S]*?\]/g, ' ');
addClassesToSet(classString, classes);
}
}

/**
* Extracts dynamic CSS classes from the content
*
* @param {string} content - The content to extract from
* @param {ClassSet} classes - The set to store extracted classes
* @returns {void}
*/
function extractDynamicClasses(content: string, classes: ClassSet): void {
const dynamicClassPattern = /(?<=^|\s):class\s*=\s*(['"])((?:(?!\1).|\n)*)\1/g;
let match: RegExpExecArray | null;

while ((match = dynamicClassPattern.exec(content)) !== null) {
const classBinding = match[2];
if (classBinding.startsWith('{') && classBinding.endsWith('}')) {
processObjectSyntax(classBinding, classes);
} else if (classBinding.startsWith('[') && classBinding.endsWith(']')) {
processArraySyntax(classBinding, classes);
} else {
processSimpleBinding(classBinding, classes);
}
}
}

/**
* Extracts CSS selectors or classes from a given content string
*
* @param {string} content - The CSS content to extract from
* @param {ExtractOptions} [options={ extractOnly: 'classes' }] - Extraction options
* @returns {string[]} An array of CSS selectors or classes
* @throws {Error} If an invalid extractOnly option is provided
*/
export function extractClassesFromCss(content: string, { extractOnly = 'classes' }: ExtractOptions = {}): string[] {
validateExtractOption(extractOnly);
content = removeBackgroundImages(content);
content = removeUrlFunctions(content);

const pattern = getExtractionPattern(extractOnly);
const items: Set<string> = new Set<string>();

let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
processMatch(match, extractOnly, items);
}

return Array.from(items);
}

/**
* Validates the extractOnly option
*
* @param {string} extractOnly - The option to validate
* @throws {Error} If the option is invalid
* @returns {asserts extractOnly is 'classes' | 'selectors'}
*/
function validateExtractOption(extractOnly: string): asserts extractOnly is 'classes' | 'selectors' {
if (extractOnly !== 'classes' && extractOnly !== 'selectors') {
throw new Error("Invalid 'extractOnly' option. Must be either 'classes' or 'selectors'.");
}

if (extractOnly === 'selectors') {
console.warn('Warning: Selector extraction may be incomplete or inaccurate. Some selectors might be identified, but full accuracy is not guaranteed.');
}
}

/**
* Processes Twig constructs in a class string
*
* @param {string} classString - The class string to process
* @param {ClassSet} classes - The set to store extracted classes
* @returns {string} The processed class string
*/
function processTwigConstructs(classString: string, classes: ClassSet): string {
return classString.replace(/{%[\s\S]*?%}/g, (twigConstruct) => {
const innerClasses = twigConstruct.match(/['"]([^'"]+)['"]/g) || [];
innerClasses.forEach((cls) => {
cls.replace(/['"]/g, '').split(/\s+/).forEach((c) => classes.add(c));
});
return ' ';
});
}

/**
* Processes interpolations in a class string
*
* @param {string} classString - The class string to process
* @returns {string} The processed class string
*/
function processInterpolations(classString: string): string {
return classString.replace(/{{[\s\S]*?}}/g, (interpolation) => {
const ternaryMatch = interpolation.match(/\?[^:]+:/) || [];
if (ternaryMatch.length > 0) {
const [truthy, falsy] = interpolation.split(':').map((part) => (part.match(/['"]([^'"]+)['"]/g) || [])
.map((cls) => cls.replace(/['"]/g, ''))
.join(' '));
return `${truthy} ${falsy}`;
}

const potentialClasses = interpolation.match(/['"]([^'"]+)['"]/g) || [];
return potentialClasses.map((cls) => cls.replace(/['"]/g, '')).join(' ');
});
}

/**
* Adds classes from a class string to a set
*
* @param {string} classString - The class string to process
* @param {ClassSet} classes - The set to store extracted classes
* @returns {void}
*/
function addClassesToSet(classString: string, classes: ClassSet): void {
classString.split(/\s+/).forEach((cls) => {
if (cls.trim()) {
classes.add(cls.trim());
}
});
}

/**
* Processes object syntax in a class binding
*
* @param {string} classBinding - The class binding to process
* @param {ClassSet} classes - The set to store extracted classes
* @returns {void}
*/
function processObjectSyntax(classBinding: string, classes: ClassSet): void {
const classObject = classBinding.slice(1, -1).trim();
const keyValuePairs = classObject.split(',');
keyValuePairs.forEach((pair) => {
const key = pair.split(':')[0].trim();
if (key && !key.startsWith('[')) {
classes.add(key.replace(/['":]/g, ''));
}
});
}

/**
* Processes array syntax in a class binding
*
* @param {string} classBinding - The class binding to process
* @param {ClassSet} classes - The set to store extracted classes
* @returns {void}
*/
function processArraySyntax(classBinding: string, classes: ClassSet): void {
const classArray = classBinding.slice(1, -1).split(/,(?![^{]*})/);
classArray.forEach((item) => {
item = item.trim();

if ((item.startsWith("'") && item.endsWith("'")) || (item.startsWith('"') && item.endsWith('"'))) {
classes.add(item.slice(1, -1));
} else if (item.startsWith('{')) {
const objectClasses = item.match(/'([^']+)'/g);
if (objectClasses) {
objectClasses.forEach((cls) => classes.add(cls.slice(1, -1)));
}
}
});
}

/**
* Processes a simple class binding
*
* @param {string} classBinding - The class binding to process
* @param {ClassSet} classes - The set to store extracted classes
* @returns {void}
*/
function processSimpleBinding(classBinding: string, classes: ClassSet): void {
const possibleClasses = classBinding.match(/['"]([^'"]+)['"]/g);
if (possibleClasses) {
possibleClasses.forEach((cls) => {
classes.add(cls.replace(/['"]/g, '').trim());
});
}
}

/**
* Gets the extraction pattern based on the extraction type
*
* @param {'classes' | 'selectors'} extractOnly - The type of extraction
* @returns {RegExp} The extraction pattern
*/
function getExtractionPattern(extractOnly: 'classes' | 'selectors'): RegExp {
return extractOnly === 'classes'
? /\.(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)/g
: /([^{}]+)(?=\s*\{)/g;
}

/**
* Processes a regex match based on the extraction type
*
* @param {RegExpExecArray} match - The regex match result
* @param {'classes' | 'selectors'} extractOnly - The type of extraction
* @param {Set<string>} items - The set to store extracted items
* @returns {void}
*/
function processMatch(match: RegExpExecArray, extractOnly: 'classes' | 'selectors', items: Set<string>): void {
if (extractOnly === 'classes') {
const className = match[1];
if (isValidClassName(className)) {
items.add(className);
}
} else {
match[1].split(',').forEach((selector) => {
const trimmedSelector = selector.trim();
if (trimmedSelector) {
items.add(trimmedSelector);
}
});
}
}
Loading

0 comments on commit 489429f

Please sign in to comment.