angular-css-shrink
Version:
Angular post-processing to reduce css file size by filtering only relevant css classes
318 lines (290 loc) • 9.18 kB
JavaScript
// SPDX-FileCopyrightText: 2021 Orange SA
// SPDX-License-Identifier: MIT
/*
* #%L
* Angular css shrink
*
* Module name: angular-css-shrink
* Version: 0.1-BETA
* Created: 2021-03-01 by Julien Faure
* %%
* Copyright (C) 2021 Orange
* %%
* The license and distribution terms in 'MIT' for this file may be found
* at http://spdx.org/licenses/MIT .
* #L%
*/
var fs = require('fs');
var esprima = require('esprima');
var cssParser = require('css');
const { RawSource, SourceMapSource } = require('webpack-sources');
class AngularCssShrink {
constructor(options) {
this.logger = {};
this.options = {};
this.options.debug = options && options.debug ? options.debug : false;
this.options.regExp = options && options.regExp ? options.regExp : new RegExp(/[^a-zA-Z0-9_\-]/g);
this.options.minClassLength = options && options.minClassLength ? options.minClassLength : 1;
}
/**
* Function to check if the class should be keeped or not
* @date 2021-03-24
* @param {any} selectors input from css parsing
* @param {MAp<String>} angularClasses map with all detected classes in js
* @returns {boolean}
*/
keepIt(selectors, angularClasses) {
if (selectors[0][0] != '.') {
// its not a class keep the input
return true;
}
// transform the selector to isolate class name (after between the . and othe char)
let selectorsClean = [];
selectors.forEach((className) => {
if (className[0] === '.') {
className = className.slice(1);
}
className = className.replace(this.options.regExp, ' ');
if (className.indexOf(' ') > -1) {
className = className.split(' ')[0];
}
if (className != '') {
selectorsClean.push(className);
}
// o deal with boosted 5 modales
if (className.startsWith('modal-')) {
selectorsClean.push(className.slice(6));
}
});
let found = false;
selectorsClean.forEach((sc) => {
if (angularClasses.has(sc) || angularClasses.has(sc)) {
found = true;
}
});
return found;
}
/**
* Extact all class from js files by parsing all String tokens
* @date 2021-03-24
* @param {Array<String>} jsCodes array of js code stringt
* @returns {Map<String>}
*/
extractAngularClass(jsCodes) {
let jscode = jsCodes.join(' ');
// for new modal sizing
let classList = new Map();
const code = esprima.tokenize(jscode);
const clist = code.filter((t) => t.type == 'String').map((t) => t.value);
console.log(clist.length);
clist.forEach((c) => {
c = c.substring(1, c.length - 1);
c = c.trim();
// replace all special char by space (class should contain olny char, number _ and - )
c = c.replace(this.options.regExp, ' ');
if (c.indexOf(' ') > -1) {
const spacedClist = c.split(' ').filter((ex) => ex != '');
spacedClist.forEach((c) => {
if (classList.has(c) === false && c.length > this.options.minClassLength) {
classList.set(c, true);
}
});
} else {
if (c.length > this.options.minClassLength) {
if (classList.has(c) === false) {
classList.set(c, true);
}
}
}
});
return classList;
}
/**
* filter css files with classes
* @date 2021-03-24
* @param {Map<String>} angularClasses List of angular classes to keep
* @param {String} cssraw Css file
* @param {any} logger
* @returns {String} CSS code
*/
angularCssShrink(angularClasses, cssraw, logger) {
logger.log('\n num of angular classes', angularClasses.size);
let filterMedia = true;
// parse css
var ast = cssParser.parse(cssraw);
ast.stylesheet.rules = ast.stylesheet.rules.filter((node) => {
if (node.type == 'rule') {
return this.keepIt(node.selectors, angularClasses);
} else {
if (filterMedia) {
if (node.type == 'media') {
let media_rules = node.rules;
media_rules = media_rules.filter((subnode) => {
if (subnode.type == 'rule') {
return this.keepIt(subnode.selectors, angularClasses);
} else {
return true;
}
});
node.rules = media_rules;
return true;
} else {
return true;
}
} else {
return true;
}
}
});
var result = cssParser.stringify(ast, { compress: true });
logger.info(
'before :',
cssraw.length,
'after:',
result.length,
'gain: ',
((cssraw.length - result.length) / (cssraw.length + 0.0001)) * 100,
'%'
);
return result;
}
/**
* extact code (css or js) from compilation process
* @date 2021-03-24
* @param {any} compilation webpack compilation object
* @param {any} name file name
* @returns {any}
*/
getAsset(compilation, name) {
// New API
if (compilation.getAsset) {
return compilation.getAsset(name);
}
if (compilation.assets[name]) {
return { name, source: compilation.assets[name], info: {} };
}
}
/**
* Main otimization process
* @date 2021-03-24
* @param {any} webpack compilation object
* @param {Array<String>} jsAssets array of js files
* @param {any} cssAssests array of css files
* @param {any} logger
* @returns {void}
*/
optimize(compilation, logger) {
let jsAssets = [];
let cssAssets = [];
const emittedAssets = compilation.getAssets().map((a) => a.name);
emittedAssets.forEach((emiA) => {
if (emiA.endsWith('js')) {
try {
const { source: inputSource } = this.getAsset(compilation, emiA);
jsAssets.push(inputSource.source());
} catch (error) {
logger.warn('Can not collect js assets ', emiA, error.message);
}
}
if (emiA.endsWith('css')) {
cssAssets.push(emiA);
}
});
if (this.options.debug) {
fs.writeFile('css-shrink-debug-alljs.js', jsAssets.join(''), (err) => {
if (err) throw err;
});
logger.log('Saved!');
}
const classlist = this.extractAngularClass(jsAssets, logger);
logger.info('Founded ' + classlist.size + ' potential classes');
if (this.options.debug) {
logger.log('write css-shrink-debug-classlist.txt');
fs.writeFile('css-shrink-debug-classlist.txt', [...classlist.keys()].join('\n'), function (err) {
if (err) throw err;
});
}
cssAssets.forEach((name) => {
logger.info('Shrink ' + name);
const { source: inputSource, info } = this.getAsset(compilation, name);
let output = {};
let input;
let inputSourceMap;
if (inputSource.sourceAndMap) {
const { source, map } = inputSource.sourceAndMap();
input = source;
if (map) {
inputSourceMap = map;
}
} else {
input = inputSource.source();
inputSourceMap = null;
}
if (this.options.debug) {
logger.info('write css-shrink-debug-original.css');
fs.writeFile('css-shrink-debug-original.css', input, function (err) {
if (err) throw err;
});
}
let code = this.angularCssShrink(classlist, input, logger);
output = {
code: code,
map: null,
warnings: []
};
if (output.map) {
output.source = new SourceMapSource(output.code, name, output.map, input, inputSourceMap, true);
} else {
output.source = new RawSource(output.code);
}
const newInfo = { ...info, minimized: true };
const { source } = output;
compilation.updateAsset(name, source, newInfo);
});
}
/**
* Collect compiled and optimized js and css files and store them in table
* @date 2021-03-24
* @param {any} compilation webPack compilation object
* @param {any} name name of the compiled asset
* @param {Array<String>} jsAssets
* @param {Array<String>} cssAssest
* @param {any} logger
* @returns {any}
*/
async collect(compilation, name, jsAssets, cssAssest, logger) {
logger.info('Detect ', name);
if (name.endsWith('.js')) {
logger.log('Detect ', name);
try {
const { source: inputSource } = this.getAsset(compilation, name);
jsAssets.push(inputSource.source());
} catch (error) {
logger.warn('Can not collect js assets ', name, error.message);
}
}
if (name.endsWith('.css')) {
logger.log('Detect ', name);
try {
cssAssest.push(name);
} catch (error) {
logger.warn('Can not collect css assets ', name);
}
}
}
/**
* Main webpackcall
* @date 2021-03-24
* @param {any} compiler webpack compiler
* @returns {void}
*/
apply(compiler) {
const pluginName = this.constructor.name;
const logger = compiler.getInfrastructureLogger(pluginName);
logger.log('Starting process');
compiler.hooks.emit.tap(pluginName, (compilation) => {
this.optimize(compilation, logger);
});
}
}
module.exports = AngularCssShrink;