diff --git a/README.md b/README.md index 7403d75a..0f8e81a9 100644 --- a/README.md +++ b/README.md @@ -2008,6 +2008,47 @@ import styles from "Component.module.scss"; ctx.fillStyle = `${svars.colorBackgroundCanvas}`; ``` +### Plugin: One Letter CSS (with hash length check) + +For efficient gzip/br compression, plugin combine css hash via one symbol name, +as a classname position at file, with filepath `hash:base64:8`, to have strong sequences + +**webpack.config.js** + +```js +const { HashLenSuggest, OneLetterCss } = require('css-loader/plugins'); +const MyOneLetterCss = new OneLetterCss(); + +const cssHashLen = 8; + +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + loader: 'css-loader', + options: { + modules: { + mode: 'local', + localIdentName: `[hash:base64:${cssHashLen}]`, + // for develop + // localIdentName: `[local]__[hash:base64:${cssHashLen}]`, + getLocalIdent: MyOneLetterCss.getLocalIdent, + }, + }, + }, + ], + }, + plugins: [ + ...plugins, + new HashLenSuggest({ + instance: MyOneLetterCss, + selectedHashLen: cssHashLen, + }), + ], +}; +``` + ## Contributing Please take a moment to read our contributing guidelines if you haven't yet done so. diff --git a/src/plugins/hash-len-suggest.js b/src/plugins/hash-len-suggest.js new file mode 100644 index 00000000..df87c02b --- /dev/null +++ b/src/plugins/hash-len-suggest.js @@ -0,0 +1,59 @@ +const PLUGIN_NAME = 'Hash length suggest'; + +export default class HashLenSuggest { + constructor({ instance, selectedHashLen }) { + this.instance = instance; + this.selectedHashLen = selectedHashLen; + this.logger = null; + } + + apply(compiler) { + compiler.plugin('done', this.run); + + this.logger = compiler.getInfrastructureLogger(PLUGIN_NAME); + } + + run() { + let data = this.instance.getStat(); + const matchLen = {}; + const base = {}; + + Object.values(data).forEach(({ name }) => { + for (let len = 1; len <= name.length; len += 1) { + base[len] = base[len] || {}; + const hash = name.substr(0, len); + + if (base[len][hash]) { + matchLen[len] = matchLen[len] || 0; + matchLen[len] += 1; + } else { + base[len][hash] = 1; + } + } + }); + + data = null; + + const { logger, selectedHashLen } = this; + + logger.log('Suggest Minify Plugin'); + logger.log('Matched length (len: number):', matchLen); + + if (matchLen[selectedHashLen]) { + logger.log( + `🚫 You can't use selected hash length (${selectedHashLen}). Increase the hash length.` + ); + process.exit(1); + } else { + logger.log(`Selected hash length (${selectedHashLen}) is OK.`); + + if (!matchLen[selectedHashLen - 1]) { + logger.log( + `🎉 You can decrease the hash length (${selectedHashLen} -> ${ + selectedHashLen - 1 + }).` + ); + } + } + } +} diff --git a/src/plugins/index.js b/src/plugins/index.js index 22e1bf60..942d2645 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -1,5 +1,7 @@ -import importParser from "./postcss-import-parser"; -import icssParser from "./postcss-icss-parser"; -import urlParser from "./postcss-url-parser"; +import importParser from './postcss-import-parser'; +import icssParser from './postcss-icss-parser'; +import urlParser from './postcss-url-parser'; +import HashLenSuggest from './hash-len-suggest'; +import OneLetterCss from './one-letter-css'; -export { importParser, icssParser, urlParser }; +export { HashLenSuggest, OneLetterCss, importParser, icssParser, urlParser }; diff --git a/src/plugins/one-letter-css.js b/src/plugins/one-letter-css.js new file mode 100644 index 00000000..75834420 --- /dev/null +++ b/src/plugins/one-letter-css.js @@ -0,0 +1,209 @@ +const { interpolateName } = require('loader-utils'); + +/** + * Change css-classes to 64-bit prefix (by class position at file) + hash postfix (by file path) + */ + +// Parse encoding string, or get default values +const getRule = (externalRule) => { + let iRule = { + type: 'hash', + rule: 'base64', + hashLen: 8, + val: '', + }; + + iRule.val = `[${iRule.type}:${iRule.rule}:${iRule.hashLen}]`; + + const matchHashRule = + externalRule + .replace(/_/g, '') + .match(/^(?:\[local])*\[([a-z\d]+):([a-z\d]+):(\d+)]$/) || []; + + if (matchHashRule.length >= 4) { + const [, type, rule, hashLen] = matchHashRule; + + iRule = { + type, + rule, + hashLen, + val: `[${type}:${rule}:${hashLen}]`, + }; + } + + return iRule; +}; + +export default class OneLetterCssClasses { + constructor() { + // Save separators points from ascii-table + this.a = 'a'.charCodeAt(0); + this.A = 'A'.charCodeAt(0); + this.zero = '0'.charCodeAt(0); + this.files = {}; + // [a-zA-Z\d_-] + this.encoderSize = 64; + this.symbolsArea = { + // a-z + az: 26, + // A-Z + AZ: 52, + // _ + under: 53, + // 0-9 | \d + digit: 63, + // - + // dash: 64 + }; + // prevent loop hell + this.maxLoop = 5; + this.rootPathLen = process.cwd().length; + } + + getSingleSymbol(n) { + const { + a, + A, + zero, + encoderSize, + symbolsArea: { az, AZ, under, digit }, + } = this; + + if (!n) { + // console.error(`!n, n=${n}`); + return ''; + } + + if (n > encoderSize) { + // console.error(`n > ${encoderSize}, n=${n}`); + return ''; + } + + // work with 1 <= n <= 64 + if (n <= az) { + return String.fromCharCode(n - 1 + a); + } + + if (n <= AZ) { + return String.fromCharCode(n - 1 - az + A); + } + + if (n <= under) { + return '_'; + } + + if (n <= digit) { + return String.fromCharCode(n - 1 - under + zero); + } + + return '-'; + } + + /** Encode classname by position at file, 0 - a, 1 - b, etc */ + getNamePrefix(num) { + const { maxLoop, encoderSize } = this; + + if (!num) { + return ''; + } + + let loopCount = 0; + let n = num; + let res = ''; + + // Divide at loop for 64 + // For example, from 1 to 64 - 1 step, from 65 to 4096 (64*64) - 2 steps, etc + while (n && loopCount < maxLoop) { + // Remainder of division, for 1-64 encode + let tail = n % encoderSize; + const origTail = tail; + + // Check limits, n === encoderSize. 64 % 64 = 0, but encoding for 64 + if (tail === 0) { + tail = encoderSize; + } + + // Concat encoding result + res = this.getSingleSymbol(tail) + res; + + // Check for new loop + if (Math.floor((n - 1) / encoderSize)) { + // Find the number of bits for next encoding cycle + n = (n - origTail) / encoderSize; + + // At limit value (64), go to a new circle, + // -1 to avoid (we have already encoded this) + // + if (origTail === 0) { + n -= 1; + } + } else { + n = 0; + } + + loopCount += 1; + } + + return res; + } + + /** + * Make hash + */ + getLocalIdentWithFileHash(context, localIdentName, localName) { + const { resourcePath } = context; + const { files, rootPathLen } = this; + + // To fix difference between stands, work with file path from project root + const resPath = resourcePath.substr(rootPathLen); + + // Filename at list, take his name + let fileShort = files[resPath]; + + // Filename not at list, generate new name, save + if (!fileShort) { + // parse encoding rule + const localIdentRule = getRule(localIdentName); + + const fileShortName = interpolateName(context, localIdentRule.val, { + content: resPath, + }); + + fileShort = { name: fileShortName, lastUsed: 0, ruleNames: {} }; + files[resPath] = fileShort; + } + + // Take rulename, if exists at current file + let newRuleName = fileShort.ruleNames[localName]; + + // If rulename not exist - generate new, and save + if (!newRuleName) { + // Increase rules count + fileShort.lastUsed += 1; + + // Generate new rulename + newRuleName = this.getNamePrefix(fileShort.lastUsed) + fileShort.name; + + // Save rulename + fileShort.ruleNames[localName] = newRuleName; + } + + // Check encoding settings for local development (save original rulenames) + const hasLocal = /\[local]/.test(localIdentName); + + // If develop mode - add prefix + const res = hasLocal ? `${localName}__${newRuleName}` : newRuleName; + + // Add prefix '_' for classes with '-' or digit '\d' + // or '_' (for fix collision) + return /^[\d_-]/.test(res) ? `_${res}` : res; + } + + getStat() { + const stat = { ...this.files }; + + this.files = {}; + + return stat; + } +} diff --git a/test/one-letter-css.test.js b/test/one-letter-css.test.js new file mode 100644 index 00000000..38730c38 --- /dev/null +++ b/test/one-letter-css.test.js @@ -0,0 +1,346 @@ +import { HashLenSuggest, OneLetterCss } from '../src/plugins'; + +const rootPath = process.cwd(); + +const workSets = [ + { + in: [ + { + resourcePath: `${rootPath}/file1.css`, + }, + '[hash:base64:8]', + 'theme-white', + ], + out: ['a2qCweEE5'], + }, + { + in: [ + { + resourcePath: `${rootPath}/file1.css`, + }, + '[hash:base64:8]', + 'theme-blue', + ], + out: ['b2qCweEE5'], + }, + { + in: [ + { + resourcePath: `${rootPath}/file2.css`, + }, + '[hash:base64:8]', + 'text-white', + ], + out: ['a1IcJDB21'], + }, + { + in: [ + { + resourcePath: `${rootPath}/file2.css`, + }, + '[hash:base64:8]', + 'text-blue', + ], + out: ['b1IcJDB21'], + }, + // develop mode + { + in: [ + { + resourcePath: `${rootPath}/file2.css`, + }, + '[local]__[hash:base64:8]', + 'text-blue', + ], + out: ['text-blue__b1IcJDB21'], + }, + // check shot hashLen + { + in: [ + { + resourcePath: `${rootPath}/file3.css`, + }, + '[hash:base64:4]', + 'text-orig', + ], + out: ['a39NC'], + }, + // check wrong hashRule + { + in: [ + { + resourcePath: `${rootPath}/file4.css`, + }, + '[hash:base64]', + 'text-wrong', + ], + out: ['a34TWGba1'], + }, +]; + +/* Corner cases */ +const encodingSets = [ + // [in, out] + // at zero value we have no result, empty prefix. cant use them (collisions) + // between 1st value ('' + '0aabb' = '_0aabb') & 53n value ('_' + '0aabb' = '_0aabb') + [0, ''], + // hash prefix, 1st value + [1, 'a'], + [2, 'b'], + [25, 'y'], + [26, 'z'], + [27, 'A'], + [28, 'B'], + [51, 'Y'], + [52, 'Z'], + [53, '_'], + [54, '0'], + [55, '1'], + [62, '8'], + [63, '9'], + [64, '-'], + [65, 'aa'], + [66, 'ab'], + [116, 'aZ'], + [117, 'a_'], + [118, 'a0'], + [127, 'a9'], + [128, 'a-'], + [129, 'ba'], + [130, 'bb'], + [190, 'b8'], + [191, 'b9'], + [192, 'b-'], + [193, 'ca'], + [194, 'cb'], + [4158, '-8'], + [4159, '-9'], + // last 2-symbols part, '--', 64*65 (64*64 2-symbols + 64 1-letter) + [4160, '--'], + // start 3-symbols part + [4161, 'aaa'], + [4162, 'aab'], +]; + +const MyOneLetterCss = new OneLetterCss(); + +describe('Testing work case', () => { + workSets.forEach((set) => { + it(`should check classname full generate`, () => { + expect(MyOneLetterCss.getLocalIdentWithFileHash(...set.in)).toEqual( + ...set.out + ); + }); + }); +}); + +describe('Testing encoding method', () => { + encodingSets.forEach(([valIn, valOut]) => { + it(`should check classname prefix generate`, () => { + expect(MyOneLetterCss.getNamePrefix(valIn)).toEqual(valOut); + }); + }); +}); + +describe('Testing encoding func', () => { + it('should check empty call', () => { + const result = MyOneLetterCss.getSingleSymbol(); + + expect(result).toEqual(''); + }); + + it('should check over encoding call', () => { + const result = MyOneLetterCss.getSingleSymbol(65); + + expect(result).toEqual(''); + }); +}); + +const statSample = { + '/file1.css': { + lastUsed: 2, + name: '2qCweEE5', + ruleNames: { + 'theme-blue': 'b2qCweEE5', + 'theme-white': 'a2qCweEE5', + }, + }, + '/file2.css': { + lastUsed: 2, + name: '1IcJDB21', + ruleNames: { + 'text-blue': 'b1IcJDB21', + 'text-white': 'a1IcJDB21', + }, + }, + '/file3.css': { + lastUsed: 1, + name: '39NC', + ruleNames: { + 'text-orig': 'a39NC', + }, + }, + '/file4.css': { + lastUsed: 1, + name: '34TWGba1', + ruleNames: { + 'text-wrong': 'a34TWGba1', + }, + }, +}; + +it('should check getStat()', () => { + const result = MyOneLetterCss.getStat(); + + expect(result).toEqual(statSample); +}); + +it('should check prefix when [d-] first letter at result', () => { + const hashRule = '[hash:base64:1]'; + const filePath = { + resourcePath: `${rootPath}/myFilePath.css`, + }; + let result = ''; + + // look for symbols, needed for prefix + for (let i = 1; i <= 53; i += 1) { + const className = `a${i}`; + + result = MyOneLetterCss.getLocalIdentWithFileHash( + filePath, + hashRule, + className + ); + } + + expect(result).toEqual('__2'); + + // when change algorithm, we need check values for collisions + // looking for collisions (check 1st 1kk values) + // const hashes = {}; + // + // for (let i = 1; i <= 1000000; i += 1) { + // const className = `a${i}`; + // + // result = MyOneLetterCss.getLocalIdentWithFileHash(filePath, hashRule, className); + // + // hashes[result] = hashes[result] || { i: [], count: 0 }; + // hashes[result].count += 1; + // hashes[result].i.push(i); + // } + // + // const collisions = []; + // + // Object.entries(hashes).forEach(([hash, { i, count }]) => { + // if (count > 1) { + // collisions.push({ hash, count, i }); + // } + // }); + // + // expect(collisions).toEqual([]); +}); + +/* eslint-disable class-methods-use-this */ +class MockOneLetterCss { + getStat() { + return statSample; + } +} + +const errorFunc = console.error; +const logFunc = console.log; + +afterEach(() => { + console.error = errorFunc; + console.log = logFunc; +}); + +const HashLenSuggestSets = [ + { + cssHashLen: 1, + // log: [ + // [], + // ['Suggest Minify Plugin'], + // [ + // 'Matched length (len: number):', + // { + // 1: 1, + // }, + // ], + // ["🚫 You can't use selected hash length (1). Increase the hash length."], + // [], + // ], + // error: [], + exit: 1, + }, + { + cssHashLen: 2, + // log: [ + // [], + // ['Suggest Minify Plugin'], + // [ + // 'Matched length (len: number):', + // { + // 1: 1, + // }, + // ], + // ['Selected hash length (2) is OK.'], + // [], + // ], + // error: [], + exit: 0, + }, + { + cssHashLen: 3, + // log: [ + // [], + // ['Suggest Minify Plugin'], + // [ + // 'Matched length (len: number):', + // { + // 1: 1, + // }, + // ], + // ['Selected hash length (3) is OK.'], + // ['🎉 You can decrease the hash length (3 -> 2).'], + // [], + // ], + // error: [], + exit: 0, + }, +]; + +class MockLogger { + info() {} + log() {} + error() {} +} + +describe('Testing hash len suggest', () => { + HashLenSuggestSets.forEach(({ cssHashLen, exit }) => { + it('should check empty call', () => { + // console.log = jest.fn(); + // console.error = jest.fn(); + + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + const myMockOneLetterCss = new MockOneLetterCss(); + + /* eslint-disable no-new */ + const myHashLenSuggest = new HashLenSuggest({ + instance: myMockOneLetterCss, + selectedHashLen: cssHashLen, + }); + + myHashLenSuggest.logger = new MockLogger(); + // myHashLenSuggest.this.logger = new MockLogger(); + // myHashLenSuggest.this.logger = new MockLogger(); + myHashLenSuggest.run(); + + // expect(console.log.mock.calls).toEqual(log); + // expect(console.error.mock.calls).toEqual(error); + expect(mockExit).toHaveBeenCalledTimes(exit); + + mockExit.mockRestore(); + }); + }); +});