@sequencemedia/css-purge
Version:
A CSS tool written in Node JS as a command line app or library for the purging, burning, reducing, shortening, compressing, cleaning, trimming and formatting of duplicate, extra, excess or bloated CSS.
1,556 lines (1,297 loc) • 51.2 kB
JavaScript
import debug from 'debug'
import path from 'node:path'
import {
writeFileSync,
createReadStream,
existsSync
} from 'node:fs'
import EventEmitter from 'node:events'
import cssTools from '@adobe/css-tools'
import validUrl from 'valid-url'
import jsdom from 'jsdom'
import ROOT from '#where-am-i'
import trim from '#css/trim'
import hack from '#css/hack'
import hasHtml from '#utils/selectors/has-html'
import hasTypeMedia from '#utils/declarations/has-type-media'
import hasTypeDocument from '#utils/declarations/has-type-document'
import hasTypeSupports from '#utils/declarations/has-type-supports'
import hasTypeComment from '#utils/declarations/has-type-comment'
import getTokens from '#utils/get-tokens'
import getSelectors from '#utils/get-selectors'
import getFilePath from '#utils/get-file-path'
import getFileSizeInKB from '#utils/get-file-size-in-kb'
import getSizeInKB from '#utils/get-size-in-kb'
import roundTo from '#utils/round-to'
import {
handleCssParseError,
handleOptionsFileReadError,
handleOptionsFileWriteError,
handleCssFileReadError,
handleCssFileWriteError,
handleHtmlFileReadError
} from '#utils/errors'
import DEFAULT_OPTIONS from '#default-options/default-options' with { type: 'json' }
import DEFAULT_DECLARATION_NAMES from '#default-options/default-declaration-names' with { type: 'json' }
import removeUnused from './remove-unused.mjs'
import processRules from './process-rules.mjs'
import processValues from './process-values.mjs'
const log = debug('@sequencemedia/css-purge')
const info = debug('@sequencemedia/css-purge:info')
const {
JSDOM
} = jsdom
const DEFAULT_FILE_LOCATION = path.join(ROOT, './default.css')
const DEFAULT_OPTIONS_FILE_LOCATION = path.join(ROOT, './src/default-options/default-options.json')
function toTrim (value) {
return String(value).trim()
}
function getSummaryStatsFor (collector) {
return function getSummaryStats ({ declarations, type }) {
if (Array.isArray(declarations)) {
collector.noComments = declarations.filter(hasTypeComment).length
}
switch (type) {
case 'rule':
collector.noRules += 1
collector.noDeclarations += declarations.length
break
case 'comment':
collector.noComments += 1
break
case 'charset':
collector.noCharset += 1
break
case 'custom-media':
collector.noCustomMedia += 1
break
case 'document':
collector.noDocument += 1
break
case 'font-face':
collector.noFontFace += 1
break
case 'host':
collector.noHost += 1
break
case 'import':
collector.noImport += 1
break
case 'keyframes':
collector.noKeyframes += 1
break
case 'keyframe':
collector.noKeyframe += 1
break
case 'media':
collector.noMedia += 1
break
case 'namespace':
collector.noNamespace += 1
break
case 'page':
collector.noPage += 1
break
case 'supports':
collector.noSupports += 1
break
}
}
}
function toGroups (rules, limit = 4095) {
const {
groups
} = rules.reduce(({ groups, count }, rule) => {
const group = groups[count] ?? (groups[count] = [])
group.push(rule)
if (group.length === limit) count += 1
return { groups, count }
}, { groups: [], count: 0 })
return groups
}
class CSSPurge {
constructor () {
const eventEmitter = new EventEmitter()
const INITIAL_OPTIONS = {
...DEFAULT_OPTIONS
}
const OPTIONS = {
...INITIAL_OPTIONS
}
// let FILE_LOCATION
let OPTIONS_FILE_LOCATION
// summary
const SUMMARY = {
files: {
output_css: [],
input_css: [],
input_html: []
},
options: {
...INITIAL_OPTIONS
},
stats: {},
duplicate_rules: [],
duplicate_declarations: [],
empty_declarations: [],
selectors_removed: []
}
// stats
const STATS = {
files: {
css: [],
html: []
},
before: {
totalFileSizeKB: 0,
noNodes: 0,
noRules: 0,
noDeclarations: 0,
noComments: 0,
noCharset: 0,
noCustomMedia: 0,
noDocument: 0,
noFontFace: 0,
noHost: 0,
noImport: 0,
noKeyframes: 0,
noKeyframe: 0,
noMedia: 0,
noNamespace: 0,
noPage: 0,
noSupports: 0
},
after: {
totalFileSizeKB: 0,
noNodes: 0,
noRules: 0,
noDeclarations: 0,
noComments: 0,
noCharset: 0,
noCustomMedia: 0,
noDocument: 0,
noFontFace: 0,
noHost: 0,
noImport: 0,
noKeyframes: 0,
noKeyframe: 0,
noMedia: 0,
noNamespace: 0,
noPage: 0,
noSupports: 0
},
summary: {
savingsKB: 0,
savingsPercentage: 0,
noEmptyDeclarations: 0,
noDuplicateRules: 0,
noDuplicateDeclarations: 0,
noZerosShortened: 0,
noNamedColorsShortened: 0,
noHexColorsShortened: 0,
noRGBColorsShortened: 0,
noHSLColorsShortened: 0,
noFontsShortened: 0,
noBackgroundsShortened: 0,
noMarginsShortened: 0,
noPaddingsShortened: 0,
noListStylesShortened: 0,
noOutlinesShortened: 0,
noBordersShortened: 0,
noBorderTopsShortened: 0,
noBorderRightsShortened: 0,
noBorderBottomsShortened: 0,
noBorderLeftsShortened: 0,
noBorderTopRightBottomLeftsShortened: 0,
noBorderRadiusShortened: 0,
noLastSemiColonsTrimmed: 0,
noInlineCommentsTrimmed: 0,
noReductions: {
noNodes: 0,
noRules: 0,
noDeclarations: 0,
noComments: 0,
noCharset: 0,
noCustomMedia: 0,
noDocument: 0,
noFontFace: 0,
noHost: 0,
noImport: 0,
noKeyframes: 0,
noKeyframe: 0,
noMedia: 0,
noNamespace: 0,
noPage: 0,
noSupports: 0
}
}
}
const {
report_file_location: REPORT_FILE_LOCATION,
reduce_declarations_file_location: REDUCE_DECLARATIONS_FILE_LOCATION
} = DEFAULT_OPTIONS
let DEFAULT_OPTIONS_REPORT_FILE_LOCATION = path.join(ROOT, REPORT_FILE_LOCATION)
let DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION = path.join(ROOT, REDUCE_DECLARATIONS_FILE_LOCATION)
let HAS_READ_REDUCE_DECLARATIONS = false
const PARAMS = {
selector_properties: new Map(),
selectors: [],
declaration_names: [
...DEFAULT_DECLARATION_NAMES
]
}
function readOptions (options = {}) {
const {
css_file_location: CSS_FILE_LOCATION,
report_file_location: REPORT_FILE_LOCATION = DEFAULT_OPTIONS_REPORT_FILE_LOCATION,
reduce_declarations_file_location: REDUCE_DECLARATIONS_FILE_LOCATION = DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION
} = options
if (CSS_FILE_LOCATION) SUMMARY.files.output_css.push(CSS_FILE_LOCATION)
DEFAULT_OPTIONS_REPORT_FILE_LOCATION = REPORT_FILE_LOCATION
DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION = REDUCE_DECLARATIONS_FILE_LOCATION
Object.assign(INITIAL_OPTIONS, Object.assign(OPTIONS, { ...options, report_file_location: REPORT_FILE_LOCATION, reduce_declarations_file_location: REDUCE_DECLARATIONS_FILE_LOCATION }))
SUMMARY.options = {
...OPTIONS
}
eventEmitter.emit('DEFAULT_OPTIONS_READ_END', OPTIONS)
} // end of readOptions
function readOptionsFileLocation (fileLocation = DEFAULT_OPTIONS_FILE_LOCATION) {
let defaultOptions = ''
const readStream = createReadStream(fileLocation, 'utf8')
readStream
.on('data', (chunk) => {
defaultOptions += chunk
})
.on('end', () => {
if (defaultOptions !== '') {
let options
try {
options = JSON.parse(defaultOptions)
} catch (e) {
eventEmitter.emit('DEFAULT_OPTIONS_READ_ERROR')
handleOptionsFileReadError(e, DEFAULT_OPTIONS_FILE_LOCATION)
}
const {
css_file_location: CSS_FILE_LOCATION,
report_file_location: REPORT_FILE_LOCATION = DEFAULT_OPTIONS_REPORT_FILE_LOCATION,
reduce_declarations_file_location: REDUCE_DECLARATIONS_FILE_LOCATION = DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION
} = options
if (CSS_FILE_LOCATION) SUMMARY.files.output_css.push(CSS_FILE_LOCATION)
DEFAULT_OPTIONS_REPORT_FILE_LOCATION = REPORT_FILE_LOCATION
DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION = REDUCE_DECLARATIONS_FILE_LOCATION
Object.assign(INITIAL_OPTIONS, Object.assign(OPTIONS, { ...options, report_file_location: REPORT_FILE_LOCATION, reduce_declarations_file_location: REDUCE_DECLARATIONS_FILE_LOCATION }))
SUMMARY.options = {
...OPTIONS
}
}
eventEmitter.emit('DEFAULT_OPTIONS_READ_END', OPTIONS)
})
.on('error', (e) => {
eventEmitter.emit('DEFAULT_OPTIONS_READ_ERROR')
handleOptionsFileReadError(e, DEFAULT_OPTIONS_FILE_LOCATION)
})
return readStream
} // end of readOptionsFileLocation
function readReduceDeclarations (reduceDeclarations = {}) {
const {
declaration_names: declarationNames = [],
selectors = {}
} = reduceDeclarations
const SELECTOR_PROPERTIES = new Map()
let SELECTORS = []
let DECLARATION_NAMES = [
...DEFAULT_DECLARATION_NAMES
]
switch (typeof selectors) {
case 'object':
Object
.entries(selectors)
.forEach(([selector, properties]) => {
SELECTOR_PROPERTIES.set(selector, properties.replace(/^\s+|\s+$/g, '').replace(/\r?\n|\r/g, '').split(',').map(toTrim).filter(Boolean))
})
SELECTORS = Array.from(SELECTOR_PROPERTIES.keys())
break
case 'string':
SELECTORS = (
selectors.length
? selectors.replace(/^\s+|\s+$/g, '').replace(/\r?\n|\r/g, '').split(',').map(toTrim).filter(Boolean)
: []
)
break
}
// by name
if (typeof declarationNames === 'string') {
DECLARATION_NAMES = declarationNames.replace(/^\s+|\s+$/g, '').split(',').map(toTrim).filter(Boolean)
} else {
if (Array.isArray(declarationNames)) {
DECLARATION_NAMES = declarationNames.map(toTrim).filter(Boolean)
}
}
PARAMS.selector_properties = SELECTOR_PROPERTIES
PARAMS.selectors = SELECTORS
PARAMS.declaration_names = DECLARATION_NAMES
HAS_READ_REDUCE_DECLARATIONS = true
eventEmitter.emit('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_END', OPTIONS)
} // end of readReduceDeclarations
function readReduceDeclarationsFileLocation (fileLocation = DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION) {
let reduceDeclarations = ''
const readStream = createReadStream(fileLocation, 'utf8')
readStream
.on('data', (chunk) => {
reduceDeclarations += chunk
})
.on('end', () => {
let declarations
try {
declarations = JSON.parse(reduceDeclarations)
} catch (e) {
eventEmitter.emit('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_ERROR')
handleOptionsFileReadError(e, fileLocation)
}
const {
declaration_names: declarationNames = [],
selectors = {}
} = declarations
const SELECTOR_PROPERTIES = new Map()
let SELECTORS = []
let DECLARATION_NAMES = [
...DEFAULT_DECLARATION_NAMES
]
switch (typeof selectors) {
case 'object':
Object
.entries(selectors)
.forEach(([selector, properties]) => {
SELECTOR_PROPERTIES.set(selector, properties.replace(/^\s+|\s+$/g, '').replace(/\r?\n|\r/g, '').split(',').map(toTrim).filter(Boolean))
})
SELECTORS = Array.from(SELECTOR_PROPERTIES.keys())
break
case 'string':
SELECTORS = (
selectors.length
? selectors.replace(/^\s+|\s+$/g, '').replace(/\r?\n|\r/g, '').split(',').map(toTrim).filter(Boolean)
: []
)
break
}
// by name
if (typeof declarationNames === 'string') {
DECLARATION_NAMES = declarationNames.replace(/^\s+|\s+$/g, '').split(',').map(toTrim).filter(Boolean)
} else {
if (Array.isArray(declarationNames)) {
DECLARATION_NAMES = declarationNames.map(toTrim).filter(Boolean)
}
}
PARAMS.selector_properties = SELECTOR_PROPERTIES
PARAMS.selectors = SELECTORS
PARAMS.declaration_names = DECLARATION_NAMES
HAS_READ_REDUCE_DECLARATIONS = true
eventEmitter.emit('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_END', OPTIONS)
})
.on('error', (e) => {
eventEmitter.emit('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_ERROR', OPTIONS)
handleOptionsFileReadError(e, DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION)
})
} // end of readReduceDeclarationsFileLocation
function prepareSelectorsForHTML (selectors = [], html = null, options = null) {
info('Prepare selectors for HTML')
if (options) Object.assign(OPTIONS.html, options)
html = html ?? OPTIONS.fileData.join('')
delete OPTIONS.fileData
let results = []
let matches
// find values in ids
matches = html.match(/\bid\b[=](["'])(?:(?=(\\?))\2.)*?\1/gm)
if (matches) {
results = results.concat(
Array.from(
new Set(
matches
.filter(Boolean)
.map((match) => match.split('=')[1].replace(/['"]+/g, ''))
)
)
)
}
// find values in classes
matches = html.match(/\bclass\b[=](["'])(?:(?=(\\?))\2.)*?\1/gm)
if (matches) {
results = results.concat(
Array.from(
new Set(
matches
.filter(Boolean)
.map((match) => match.split('=')[1].replace(/['"]+/g, ''))
)
)
)
}
// find values in internal selectors
matches = html.match(/<style([\S\s]*?)>([\S\s]*?)<\/style>/gi)
if (matches) {
results = results.concat(
Array.from(
new Set(
matches
.filter(Boolean)
.map((match) => match.split('</')[0].split('>')[1])
.map((match) => match.match(/([#}.])([^0-9])([\S\s]*?){/g))
.filter(Boolean)
.map((match) => match.replace(/\r?\n|\r|\s/g, '').replace('{', '').replace('}', ''))
.map((match) => match.split(',').map(toTrim).filter(Boolean))
)
)
)
}
// find values in the dom
const {
window: {
document
}
} = new JSDOM(html, { contentType: 'text/html' })
selectors
.forEach((selector, i) => {
results
.forEach((result) => {
if (selector === result) selectors.splice(i, 1)
})
})
selectors
.forEach((selector, i) => {
if (document.querySelector(selector)) selectors.splice(i, 1)
})
return selectors
} // end of prepareSelectorsForHTML
function processSelectorsForHTML (rules = [], selectors = []) {
info('Process selectors for HTML')
removeUnused(rules, selectors)
processRules(rules, OPTIONS, SUMMARY, PARAMS)
// after
STATS.after.totalFileSizeKB = 0
STATS.after.noNodes = 0
STATS.after.noRules = 0
STATS.after.noDeclarations = 0
STATS.after.noComments = 0
STATS.after.noCharset = 0
STATS.after.noCustomMedia = 0
STATS.after.noDocument = 0
STATS.after.noFontFace = 0
STATS.after.noHost = 0
STATS.after.noImport = 0
STATS.after.noKeyframes = 0
STATS.after.noKeyframe = 0
STATS.after.noMedia = 0
STATS.after.noNamespace = 0
STATS.after.noPage = 0
STATS.after.noSupports = 0
STATS.after.noNodes = rules.length
rules
.filter(Boolean)
.forEach(getSummaryStatsFor(SUMMARY.stats.after))
SUMMARY.stats.summary.noReductions.noRules = SUMMARY.stats.before.noRules - SUMMARY.stats.after.noRules
SUMMARY.stats.summary.noReductions.noDeclarations = SUMMARY.stats.before.noDeclarations - SUMMARY.stats.after.noDeclarations
SUMMARY.stats.summary.noReductions.noComments = SUMMARY.stats.before.noComments - SUMMARY.stats.after.noComments
SUMMARY.stats.summary.noReductions.noCharset = SUMMARY.stats.before.noCharset - SUMMARY.stats.after.noCharset
SUMMARY.stats.summary.noReductions.noCustomMedia = SUMMARY.stats.before.noCustomMedia - SUMMARY.stats.after.noCustomMedia
SUMMARY.stats.summary.noReductions.noDocument = SUMMARY.stats.before.noDocument - SUMMARY.stats.after.noDocument
SUMMARY.stats.summary.noReductions.noFontFace = SUMMARY.stats.before.noFontFace - SUMMARY.stats.after.noFontFace
SUMMARY.stats.summary.noReductions.noHost = SUMMARY.stats.before.noHost - SUMMARY.stats.after.noHost
SUMMARY.stats.summary.noReductions.noImport = SUMMARY.stats.before.noImport - SUMMARY.stats.after.noImport
SUMMARY.stats.summary.noReductions.noKeyframes = SUMMARY.stats.before.noKeyframes - SUMMARY.stats.after.noKeyframes
SUMMARY.stats.summary.noReductions.noKeyframe = SUMMARY.stats.before.noKeyframe - SUMMARY.stats.after.noKeyframe
SUMMARY.stats.summary.noReductions.noMedia = SUMMARY.stats.before.noMedia - SUMMARY.stats.after.noMedia
SUMMARY.stats.summary.noReductions.noNamespace = SUMMARY.stats.before.noNamespace - SUMMARY.stats.after.noNamespace
SUMMARY.stats.summary.noReductions.noPage = SUMMARY.stats.before.noPage - SUMMARY.stats.after.noPage
SUMMARY.stats.summary.noReductions.noSupports = SUMMARY.stats.before.noSupports - SUMMARY.stats.after.noSupports
SUMMARY.stats.summary.noReductions.noNodes = SUMMARY.stats.before.noNodes - SUMMARY.stats.after.noNodes
return cssTools.stringify({
type: 'stylesheet',
stylesheet: {
rules
}
})
} // end of processSelectorsForHTML
async function processHTML (selectors = [], html = null, options = null) {
info('Process HTML')
// read html files
if (OPTIONS.html && OPTIONS.special_reduce_with_html) {
let {
html: htmlFiles
} = OPTIONS
// check for file or files
switch (typeof htmlFiles) {
case 'object':
{
const collector = []
Object
.values(htmlFiles)
.forEach((htmlFile) => {
getFilePath(htmlFile, ['.html', '.htm'], collector)
})
if (collector.length) {
htmlFiles = collector
}
}
break
case 'array':
{
const collector = []
htmlFiles
.forEach((htmlFile) => {
getFilePath(htmlFile, ['.html', '.htm'], collector)
})
if (collector.length) {
htmlFiles = collector
}
}
break
case 'string': // formats
{
let collection = htmlFiles.replace(/ /g, '') // comma delimited list - filename1.html, filename2.html
if (collection.includes(',')) {
collection = collection.replace(/^\s+|\s+$/g, '').split(',').map(toTrim).filter(Boolean)
const collector = []
collection
.forEach((member) => {
getFilePath(member, ['.html', '.htm'], collector)
})
if (collector.length) {
htmlFiles = collector
}
} else {
const collector = []
// string path
getFilePath(collection, ['.html', '.htm'], collector)
if (collector.length) {
htmlFiles = collector
}
}
}
break
} // end of switch
eventEmitter
.on('HTML_READ_AGAIN', async (fileIndex, fileData) => {
prepareSelectorsForHTML(selectors, html, { ...options, fileData })
await readHTMLFiles(htmlFiles, fileIndex, fileData)
})
.on('HTML_READ_END', (fileData) => {
prepareSelectorsForHTML(selectors, html, { ...options, fileData })
eventEmitter.emit('HTML_RESULTS_END', selectors)
})
await readHTMLFiles(htmlFiles)
} // end of html files check
} // end of processHTML
async function readHTMLFiles (files = [], fileIndex = 0, fileData = []) {
info('Read HTML files')
const file = files[fileIndex]
log(file)
if (validUrl.isUri(file)) {
try {
const response = await fetch(file)
const content = await response.text()
const fileSizeKB = getSizeInKB(content)
STATS.files.html.push({
fileName: file,
fileSizeKB
})
SUMMARY.files.input_html.push(file)
fileData.push(content)
const nextFileIndex = fileIndex + 1
if (nextFileIndex < files.length) {
eventEmitter.emit('HTML_READ_AGAIN', nextFileIndex, fileData)
} else {
eventEmitter.emit('HTML_READ_END', fileData)
}
} catch (e) {
eventEmitter.emit('HTML_READ_ERROR')
handleHtmlFileReadError(e, file)
}
} else {
const fileSizeKB = getFileSizeInKB(file)
STATS.files.html.push({
fileName: file,
fileSizeKB
})
SUMMARY.files.input_html.push(file)
const readHTMLStream = createReadStream(file, 'utf8')
readHTMLStream
.on('data', (chunk) => {
fileData.push(chunk)
})
.on('end', () => {
const nextFileIndex = fileIndex + 1
if (nextFileIndex < files.length) {
eventEmitter.emit('HTML_READ_AGAIN', nextFileIndex, fileData)
} else {
eventEmitter.emit('HTML_READ_END', fileData)
}
})
.on('error', (e) => {
eventEmitter.emit('HTML_READ_ERROR')
handleHtmlFileReadError(e, file)
})
}
} // end of readHTMLFiles
function processCSS (css = null, options = null, complete = () => {}) {
info('Process CSS')
function handleDefaultOptionReduceDeclarationsEnd () {
eventEmitter.removeListener('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_END', handleDefaultOptionReduceDeclarationsEnd)
if (css) {
const fileSizeKB = getSizeInKB(css)
STATS.before.totalFileSizeKB += fileSizeKB
}
// options
if (options) Object.assign(OPTIONS, options)
if (!OPTIONS.css) {
const {
file_path: FILE_PATH = DEFAULT_FILE_LOCATION
} = OPTIONS
OPTIONS.css = [FILE_PATH]
}
css = css ?? OPTIONS.fileData.join('')
delete OPTIONS.fileData
const {
_3tokenValues,
_4tokenValues,
_5tokenValues,
_6tokenValues,
_7tokenValues,
tokenComments
} = getTokens()
// tokens - allow multi-keyframe selectors
css = css.replace(/(@(-?)[a-zA-Z\-]*(keyframes)*\s[a-zA-Z\-]*(\s*,?\s*)){2,}/g, (match) => { // eslint-disable-line no-useless-escape
_7tokenValues.push(match)
return '@keyframes _7token_' + _7tokenValues.length + ''
})
// tokens - data:image
css = css.replace(/url\(\"data:image\/([a-zA-Z]*);base64,([^\"]*)\"\)/g, (match) => { // eslint-disable-line no-useless-escape
_6tokenValues.push(match)
return '_6token_dataimage_' + _6tokenValues.length + ':'
})
// remove non-standard commented lines
css = css.replace(/([^(:;,a-zA-Z0-9]|^)\/\/.*$/gm, (match) => {
STATS.summary.noInlineCommentsTrimmed += 1
if (OPTIONS.trim_keep_non_standard_inline_comments && OPTIONS.trim_comments !== true) {
return '/*' + match.substring(3, match.length) + ' */'
} else {
return ''
}
})
// hacks - **/
css = css.replace(/\/\*\*\//gm, '_1token_hck')
// hacks - *\**/
css = css.replace(/\/\*\\\*\*\//gm, '_2token_hck')
// hacks - (specialchar)property
css = css.replace(/[\!\$\&\*\(\)\=\%\+\@\,\.\/\`\[\]\#\~\?\:\<\>\|\*\/]{1}([\-\_\.]?)([a-zA-Z0-9]+):((\s\S*?));/g, (match) => { // eslint-disable-line no-useless-escape
_3tokenValues.push(match.substring(0, match.length - 1))
return '_3token_hck_' + _3tokenValues.length + ':'
})
// hacks - (;
css = css.replace(/(\(;)([\s\S]*?)(\})/g, (match) => {
_4tokenValues.push(match)
return '_4token_hck_' + _4tokenValues.length + ':}'
})
// hacks - [;
css = css.replace(/(\[;)([\s\S]*?)(\})/g, (match) => {
_5tokenValues.push(match)
return '_5token_hck_' + _5tokenValues.length + ':}'
})
// tokens - replace side comments
if (OPTIONS.trim_comments !== true) {
css = css.replace(/[;]([^\n][\s]*?)\/\*([\s\S]*?)\*\//gm, (match) => {
const i = Object.keys(tokenComments).length + 1
const k = '_cssp_sc' + i
tokenComments[k] = match
return `; /*_cssp_sc${i}*/`
})
}
const {
file_path: FILE_PATH = DEFAULT_FILE_LOCATION
} = OPTIONS
let ast
try {
ast = cssTools.parse(css, { source: FILE_PATH })
} catch (e) {
handleCssParseError(e)
}
const {
stylesheet: {
rules
}
} = ast
SUMMARY.stats = STATS
STATS.before.noNodes = rules.length
rules
.filter(Boolean)
.forEach(getSummaryStatsFor(SUMMARY.stats.before))
processRules(rules, OPTIONS, SUMMARY, PARAMS)
processValues(rules, OPTIONS, SUMMARY)
// @media rules
rules
.filter(Boolean)
.filter(hasTypeMedia)
.forEach(({ rules, media }) => {
log(`@media ${media}`)
processRules(rules, OPTIONS, SUMMARY, PARAMS)
processValues(rules, OPTIONS, SUMMARY)
})
// @document rules
if (!OPTIONS.bypass_document_rules) {
rules
.filter(Boolean)
.filter(hasTypeDocument)
.forEach(({ rules, document }) => {
log(`@document ${document}`)
processRules(rules, OPTIONS, SUMMARY, PARAMS)
processValues(rules, OPTIONS, SUMMARY)
})
}
// @supports rules
if (!OPTIONS.bypass_supports_rules) {
rules
.filter(Boolean)
.filter(hasTypeSupports)
.forEach(({ rules, supports }) => {
log(`@supports ${supports}`)
processRules(rules, OPTIONS, SUMMARY, PARAMS)
processValues(rules, OPTIONS, SUMMARY)
})
}
// charset rules
if (!OPTIONS.bypass_charset) {
rules
.forEach((rule, i) => {
if (rule) {
const {
type
} = rule
if (type === 'charset') {
const {
charset: ALPHA = ''
} = rule
rules.slice(i)
.forEach((rule, j) => {
if (rule) {
const {
type
} = rule
if (type === 'charset') {
const {
charset: OMEGA = ''
} = rule
if (ALPHA === OMEGA) {
rules.splice(j, 1) // remove charset
const siblingRule = rules[j] // rules.slice(j).shift()
if (siblingRule) {
const {
type,
comment
} = siblingRule
if (
type === 'comment' &&
comment.includes('_cssp_sc')
) {
rules.splice(j, 1) // remove comment
}
}
}
}
}
})
if (!(ALPHA.startsWith('"') && ALPHA.endsWith('"'))) {
rules.splice(i, 1) // remove charset
const siblingRule = rules[i] // rules.slice(i).shift()
if (siblingRule) {
const {
type,
comment
} = siblingRule
if (
type === 'comment' &&
comment.includes('_cssp_sc')
) {
rules.splice(i, 1) // remove comment
}
}
}
}
}
})
}
const {
special_convert_rem: SPECIAL_CONVERT_REM
} = OPTIONS
// rems - html check
if (SPECIAL_CONVERT_REM) {
const {
special_convert_rem_px: SPECIAL_CONVERT_REM_PX,
special_convert_rem_default_px: SPECIAL_CONVERT_REM_DEFAULT_PX
} = OPTIONS
const remPx = Number(SPECIAL_CONVERT_REM_PX)
const remDefaultPx = Number(SPECIAL_CONVERT_REM_DEFAULT_PX)
const FONT_SIZE = ((remPx / remDefaultPx) * 100) + '%'
if (rules.some(hasHtml)) {
rules
.forEach((rule, i, rules) => {
const {
selectors = []
} = rule
/**
* Has
*/
if (selectors.some((selector) => selector.includes('html'))) {
const {
declarations = []
} = rule
/**
* Has not
*/
if (!declarations.some(({ property }) => property === 'font-size')) {
/**
* Add to the start
*/
declarations.unshift({
type: 'declaration',
property: 'font-size',
value: FONT_SIZE
})
/**
* Put at the start
*/
rules.unshift(rules.splice(i, 1).shift())
}
}
})
} else {
rules.unshift({
type: 'rule',
selectors: ['html'],
declarations: [
{
type: 'declaration',
property: 'font-size',
value: FONT_SIZE
}
]
})
}
} // end of rems - html check
/// charset check
if (!OPTIONS.bypass_charset) {
if (rules.length >= 2) {
const [
RULE_ONE,
RULE_TWO
] = rules
if (
RULE_ONE.type === 'comment' &&
RULE_TWO.type === 'charset'
) {
rules.splice(0, 1)
}
}
}
/// end of charset check
// after
STATS.after.noNodes = rules.length
rules
.filter(Boolean)
.forEach(getSummaryStatsFor(SUMMARY.stats.after))
SUMMARY.stats.summary.noReductions.noRules = SUMMARY.stats.before.noRules - SUMMARY.stats.after.noRules
SUMMARY.stats.summary.noReductions.noDeclarations = SUMMARY.stats.before.noDeclarations - SUMMARY.stats.after.noDeclarations
SUMMARY.stats.summary.noReductions.noComments = SUMMARY.stats.before.noComments - SUMMARY.stats.after.noComments
SUMMARY.stats.summary.noReductions.noCharset = SUMMARY.stats.before.noCharset - SUMMARY.stats.after.noCharset
SUMMARY.stats.summary.noReductions.noCustomMedia = SUMMARY.stats.before.noCustomMedia - SUMMARY.stats.after.noCustomMedia
SUMMARY.stats.summary.noReductions.noDocument = SUMMARY.stats.before.noDocument - SUMMARY.stats.after.noDocument
SUMMARY.stats.summary.noReductions.noFontFace = SUMMARY.stats.before.noFontFace - SUMMARY.stats.after.noFontFace
SUMMARY.stats.summary.noReductions.noHost = SUMMARY.stats.before.noHost - SUMMARY.stats.after.noHost
SUMMARY.stats.summary.noReductions.noImport = SUMMARY.stats.before.noImport - SUMMARY.stats.after.noImport
SUMMARY.stats.summary.noReductions.noKeyframes = SUMMARY.stats.before.noKeyframes - SUMMARY.stats.after.noKeyframes
SUMMARY.stats.summary.noReductions.noKeyframe = SUMMARY.stats.before.noKeyframe - SUMMARY.stats.after.noKeyframe
SUMMARY.stats.summary.noReductions.noMedia = SUMMARY.stats.before.noMedia - SUMMARY.stats.after.noMedia
SUMMARY.stats.summary.noReductions.noNamespace = SUMMARY.stats.before.noNamespace - SUMMARY.stats.after.noNamespace
SUMMARY.stats.summary.noReductions.noPage = SUMMARY.stats.before.noPage - SUMMARY.stats.after.noPage
SUMMARY.stats.summary.noReductions.noSupports = SUMMARY.stats.before.noSupports - SUMMARY.stats.after.noSupports
SUMMARY.stats.summary.noReductions.noNodes = SUMMARY.stats.before.noNodes - SUMMARY.stats.after.noNodes
// prepare output
let outputCSS = cssTools.stringify({
type: 'stylesheet',
stylesheet: {
rules
}
})
// Detect via JS
// Detect via HTML
if (OPTIONS.html && OPTIONS.special_reduce_with_html) {
const {
file_path: FILE_PATH = DEFAULT_FILE_LOCATION
} = OPTIONS
let ast
try {
ast = cssTools.parse(outputCSS, { source: FILE_PATH })
} catch (e) {
handleCssParseError(e)
}
const {
stylesheet: {
rules
}
} = ast
let selectors = []
const {
special_reduce_with_html_ignore_selectors: SPECIAL_REDUCE_WITH_HTML_IGNORE_SELECTORS
} = OPTIONS
rules
.filter(Boolean)
.forEach((rule) => {
const { type } = rule
if (type === 'rule') {
getSelectors(rule, selectors, SPECIAL_REDUCE_WITH_HTML_IGNORE_SELECTORS)
} else {
if (
type === 'media' ||
type === 'document' ||
type === 'supports'
) {
rule.rules
.forEach((rule) => {
getSelectors(rule, selectors, SPECIAL_REDUCE_WITH_HTML_IGNORE_SELECTORS)
})
}
}
})
// remove duplicates
selectors = Array.from(new Set(selectors))
// process selectors returned from processing HTML
eventEmitter
.on('HTML_RESULTS_END', (selectorsRemoved) => {
SUMMARY.selectors_removed = selectorsRemoved
let css = processSelectorsForHTML(rules, selectors)
const {
css_file_location: CSS_FILE_LOCATION
} = OPTIONS
if (CSS_FILE_LOCATION) css = writeCSSFiles(css, CSS_FILE_LOCATION)
else {
css = trim(css, OPTIONS, SUMMARY)
css = hack(css, OPTIONS, SUMMARY, getTokens())
const fileSizeKB = getSizeInKB(css) // getSizeInKB(css) / 1000
SUMMARY.stats.after.totalFileSizeKB += fileSizeKB
}
SUMMARY.stats.summary.savingsKB = roundTo(SUMMARY.stats.before.totalFileSizeKB - SUMMARY.stats.after.totalFileSizeKB, 4)
SUMMARY.stats.summary.savingsPercentage = roundTo(SUMMARY.stats.summary.savingsKB / SUMMARY.stats.before.totalFileSizeKB * 100, 2)
complete(null, css)
const {
report: REPORT
} = OPTIONS
if (REPORT) {
const {
report_file_location: REPORT_FILE_LOCATION = DEFAULT_OPTIONS_REPORT_FILE_LOCATION
} = OPTIONS
try {
SUMMARY.options = (
Object.fromEntries(
Object
.entries(OPTIONS)
.sort(([alpha], [omega]) => alpha.localeCompare(omega))
)
)
writeFileSync(REPORT_FILE_LOCATION, JSON.stringify(SUMMARY, null, 2))
} catch (e) {
handleOptionsFileWriteError(e, REPORT_FILE_LOCATION)
}
}
const {
verbose: VERBOSE
} = OPTIONS
if (VERBOSE) {
console.table({
Before: {
KB: roundTo(SUMMARY.stats.before.totalFileSizeKB, 4)
},
After: {
KB: roundTo(SUMMARY.stats.after.totalFileSizeKB, 4)
},
Saved: {
KB: roundTo(SUMMARY.stats.summary.savingsKB, 4),
'%': roundTo(SUMMARY.stats.summary.savingsPercentage, 2)
}
})
}
})
processHTML(selectors)
} else { // end of special_reduce_with_html
const {
css_file_location: CSS_FILE_LOCATION
} = OPTIONS
if (CSS_FILE_LOCATION) outputCSS = writeCSSFiles(outputCSS, CSS_FILE_LOCATION)
else {
outputCSS = trim(outputCSS, OPTIONS, SUMMARY)
outputCSS = hack(outputCSS, OPTIONS, SUMMARY, getTokens())
const fileSizeKB = getSizeInKB(outputCSS) // getSizeInKB(css) / 1000
SUMMARY.stats.after.totalFileSizeKB += fileSizeKB
}
SUMMARY.stats.summary.savingsKB = roundTo(SUMMARY.stats.before.totalFileSizeKB - SUMMARY.stats.after.totalFileSizeKB, 4)
SUMMARY.stats.summary.savingsPercentage = roundTo(SUMMARY.stats.summary.savingsKB / SUMMARY.stats.before.totalFileSizeKB * 100, 2)
complete(null, outputCSS)
const {
report: REPORT
} = OPTIONS
if (REPORT) {
const {
report_file_location: REPORT_FILE_LOCATION = DEFAULT_OPTIONS_REPORT_FILE_LOCATION
} = OPTIONS
try {
SUMMARY.options = (
Object.fromEntries(
Object
.entries(OPTIONS)
.sort(([alpha], [omega]) => alpha.localeCompare(omega))
)
)
writeFileSync(REPORT_FILE_LOCATION, JSON.stringify(SUMMARY, null, 2))
} catch (e) {
handleOptionsFileWriteError(e, REPORT_FILE_LOCATION)
}
}
const {
verbose: VERBOSE
} = OPTIONS
if (VERBOSE) {
console.table({
Before: {
KB: roundTo(SUMMARY.stats.before.totalFileSizeKB, 4)
},
After: {
KB: roundTo(SUMMARY.stats.after.totalFileSizeKB, 4)
},
Saved: {
KB: roundTo(SUMMARY.stats.summary.savingsKB, 4),
'%': roundTo(SUMMARY.stats.summary.savingsPercentage, 2)
}
})
}
} // end of special_reduce_with_html
}
eventEmitter.on('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_END', handleDefaultOptionReduceDeclarationsEnd) // end of event
if (!css) eventEmitter.emit('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_END')
if (!HAS_READ_REDUCE_DECLARATIONS) {
const REDUCE_DECLARATIONS_FILE_LOCATION = OPTIONS.reduce_declarations_file_location
const reduceDeclarationsFileLocation = (
DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION === path.join(ROOT, REDUCE_DECLARATIONS_FILE_LOCATION)
? DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION
: REDUCE_DECLARATIONS_FILE_LOCATION
)
OPTIONS.reduce_declarations_file_location = reduceDeclarationsFileLocation
if (existsSync(reduceDeclarationsFileLocation)) {
readReduceDeclarationsFileLocation(reduceDeclarationsFileLocation)
} else {
if (options && !options.reduce_declarations) {
const reduceDeclarations = {
declaration_names: [
...DEFAULT_DECLARATION_NAMES
]
}
options.reduce_declarations = reduceDeclarations
options.reduce_declarations_file_location = reduceDeclarationsFileLocation
readReduceDeclarations(reduceDeclarations)
} else {
readReduceDeclarationsFileLocation(DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION)
}
}
}
} // end of processCSS
function processCSSFiles (options = INITIAL_OPTIONS, fileLocation = DEFAULT_OPTIONS_FILE_LOCATION, complete) {
info('Process CSS files')
async function handleDefaultOptionsReadEnd () {
eventEmitter.removeListener('DEFAULT_OPTIONS_READ_END', handleDefaultOptionsReadEnd)
// options (css files)
if (!OPTIONS.css) OPTIONS.css = INITIAL_OPTIONS.css
// options
if (options) Object.assign(OPTIONS, options)
let {
css: cssFiles
} = OPTIONS
if (cssFiles) {
// check for file or files
switch (typeof cssFiles) {
case 'array':
{
const collector = []
cssFiles
.forEach((file) => {
getFilePath(file, ['.css'], collector)
})
if (collector.length) {
cssFiles = collector
}
}
break
case 'string':
{
// formats
let collection = cssFiles.replace(/ /g, '')
if (collection.includes(',')) { // comma delimited list - filename1.css, filename2.css
collection = collection.split(',').map(toTrim).filter(Boolean)
const collector = []
collection
.forEach((member) => {
getFilePath(member, ['.css'], collector)
})
if (collector.length) {
cssFiles = collector
}
} else {
const collector = []
// string path
getFilePath(collection, ['.css'], collector)
if (collector.length) {
cssFiles = collector
}
}
}
break
} // end of switch
fileLocation = cssFiles.toString()
eventEmitter
.on('CSS_READ_AGAIN', async (fileIndex, fileData) => {
await readCSSFiles(cssFiles, fileIndex, fileData)
})
.on('CSS_READ_END', (fileData) => {
processCSS(null, { ...OPTIONS, fileData }, complete)
})
await readCSSFiles(cssFiles)
}
}
function handleDefaultOptionsReduceDeclarationsEnd () {
eventEmitter.removeListener('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_END', handleDefaultOptionsReduceDeclarationsEnd)
if (OPTIONS_FILE_LOCATION !== fileLocation) { // don't read same config
eventEmitter.on('DEFAULT_OPTIONS_READ_END', handleDefaultOptionsReadEnd) // end of config read
}
OPTIONS_FILE_LOCATION = fileLocation
if (fileLocation !== 'cmd_default') {
readOptionsFileLocation(fileLocation)
} else {
if (fileLocation === 'cmd_default') {
readOptions(options)
}
}
}
eventEmitter.on('DEFAULT_OPTIONS_REDUCE_DECLARATIONS_END', handleDefaultOptionsReduceDeclarationsEnd) // end of reduce config read
if (!HAS_READ_REDUCE_DECLARATIONS) {
const REDUCE_DECLARATIONS_FILE_LOCATION = OPTIONS.reduce_declarations_file_location
const reduceDeclarationsFileLocation = (
DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION === path.join(ROOT, REDUCE_DECLARATIONS_FILE_LOCATION)
? DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION
: REDUCE_DECLARATIONS_FILE_LOCATION
)
OPTIONS.reduce_declarations_file_location = reduceDeclarationsFileLocation
if (existsSync(reduceDeclarationsFileLocation)) {
readReduceDeclarationsFileLocation(reduceDeclarationsFileLocation)
} else {
if (options && !options.reduce_declarations) {
const reduceDeclarations = {
declaration_names: [
...DEFAULT_DECLARATION_NAMES
]
}
options.reduce_declarations = reduceDeclarations
options.reduce_declarations_file_location = reduceDeclarationsFileLocation
readReduceDeclarations(reduceDeclarations)
} else {
readReduceDeclarationsFileLocation(DEFAULT_OPTIONS_REDUCE_DECLARATIONS_FILE_LOCATION)
}
}
}
} // end of processCSSFiles
async function readCSSFiles (files = [], fileIndex = 0, fileData = []) {
info('Read CSS files')
const file = files[fileIndex]
log(file)
if (validUrl.isUri(file)) {
try {
const response = await fetch(file)
const content = await response.text()
const fileSizeKB = getSizeInKB(content)
STATS.files.css.push({
fileName: file,
fileSizeKB
})
STATS.before.totalFileSizeKB += fileSizeKB
SUMMARY.files.input_css.push(file)
fileData.push(content)
const nextFileIndex = fileIndex + 1
if (nextFileIndex < files.length) {
eventEmitter.emit('CSS_READ_AGAIN', nextFileIndex, fileData)
} else {
eventEmitter.emit('CSS_READ_END', fileData)
}
} catch (e) {
eventEmitter.emit('CSS_READ_ERROR')
handleCssFileReadError(e, file)
}
} else {
const fileSizeKB = getFileSizeInKB(file)
STATS.files.css.push({
fileName: file,
fileSizeKB
})
STATS.before.totalFileSizeKB += fileSizeKB
SUMMARY.files.input_css.push(file)
const readStream = createReadStream(file, 'utf8')
readStream
.on('data', (chunk) => {
fileData.push(chunk)
})
.on('end', () => {
const nextFileIndex = fileIndex + 1
if (nextFileIndex < files.length) {
eventEmitter.emit('CSS_READ_AGAIN', nextFileIndex, fileData)
} else {
eventEmitter.emit('CSS_READ_END', fileData)
}
})
.on('error', (e) => {
eventEmitter.emit('CSS_READ_ERROR')
handleCssFileReadError(e, file)
})
}
} // end of readCSSFiles
function writeCSSFiles (css = '', fileLocation) {
info('Write CSS files')
try {
let fileSizeKB = 0
const directoryPath = path.dirname(fileLocation)
const {
name
} = path.parse(fileLocation)
const {
format_group_limit: FORMAT_GROUP_LIMIT
} = OPTIONS
if (FORMAT_GROUP_LIMIT) {
if (Math.ceil(SUMMARY.stats.after.noRules / FORMAT_GROUP_LIMIT) > 1) {
let ast
try {
ast = cssTools.parse(css, { source: fileLocation })
} catch (e) {
handleCssParseError(e)
}
const {
stylesheet: {
rules
}
} = ast
toGroups(rules, FORMAT_GROUP_LIMIT)
.forEach((rules, i) => {
/**
* Redeclared so as not to modify `css` in scope
*/
let css = cssTools.stringify({
type: 'stylesheet',
stylesheet: {
rules
}
})
css = trim(css, OPTIONS, SUMMARY)
css = hack(css, OPTIONS, SUMMARY, getTokens())
const filePath = path.join(directoryPath, `${name}_${i}.css`)
writeFil