import loconfig from '../helpers/config.js'; import message from '../helpers/message.js'; import notification from '../helpers/notification.js'; import { createProcessor, pluginsMap as postcssPluginsMap, supportsPostCSS } from '../helpers/postcss.js'; import resolve from '../helpers/template.js'; import { merge } from '../utils/index.js'; import { writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; import * as sass from 'sass'; import { PurgeCSS } from 'purgecss'; let postcssProcessor; /** * @const {object} defaultSassOptions - The default shared Sass options. * @const {object} developmentSassOptions - The predefined Sass options for development. * @const {object} productionSassOptions - The predefined Sass options for production. */ export const defaultSassOptions = { sourceMapIncludeSources: true, sourceMap: true, }; export const developmentSassOptions = Object.assign({}, defaultSassOptions, { style: 'expanded', }); export const productionSassOptions = Object.assign({}, defaultSassOptions, { style: 'compressed', }); /** * @const {object} defaultPostCSSOptions - The default shared PostCSS options. * @const {object} developmentPostCSSOptions - The predefined PostCSS options for development. * @const {object} productionPostCSSOptions - The predefined PostCSS options for production. */ export const defaultPostCSSOptions = { processor: { map: { annotation: false, inline: false, sourcesContent: true, }, }, }; export const developmentPostCSSOptions = Object.assign({}, defaultPostCSSOptions); export const productionPostCSSOptions = Object.assign({}, defaultPostCSSOptions); /** * @const {object|boolean} developmentStylesArgs - The predefined `compileStyles()` options for development. * @const {object|boolean} productionStylesArgs - The predefined `compileStyles()` options for production. */ export const developmentStylesArgs = [ developmentSassOptions, developmentPostCSSOptions, false ]; export const productionStylesArgs = [ productionSassOptions, productionPostCSSOptions, true ]; /** * Compiles and minifies main Sass files to CSS. * * @todo Add deep merge of `postcssOptions` to better support customization * of default processor options. * * @async * @param {object} [sassOptions=null] - Customize the Sass render API options. * If `null`, default production options are used. * @param {object|boolean} [postcssOptions=null] - Customize the PostCSS processor API options. * If `null`, default production options are used. * If `false`, PostCSS processing will be ignored. * @return {Promise} */ export default async function compileStyles(sassOptions = null, postcssOptions = null, purge = true) { if (sassOptions == null) { sassOptions = productionSassOptions; } else if ( sassOptions !== developmentSassOptions && sassOptions !== productionSassOptions ) { sassOptions = merge({}, defaultSassOptions, sassOptions); } if (supportsPostCSS) { if (postcssOptions == null) { postcssOptions = productionPostCSSOptions; } else if ( postcssOptions !== false && postcssOptions !== developmentPostCSSOptions && postcssOptions !== productionPostCSSOptions ) { postcssOptions = merge({}, defaultPostCSSOptions, postcssOptions); } } /** * @async * @param {object} entry - The entrypoint to process. * @param {string[]} entry.infile - The file to process. * @param {string} entry.outfile - The file to write to. * @param {?string} [entry.label] - The task label. * Defaults to the outfile name. * @return {Promise} */ loconfig.tasks.styles?.forEach(async ({ infile, outfile, label = null }) => { const filestem = basename((outfile || 'undefined'), '.css'); const timeLabel = `${label || `${filestem}.css`} compiled in`; console.time(timeLabel); try { infile = resolve(infile); outfile = resolve(outfile); let result = sass.compile(infile, sassOptions); if (supportsPostCSS && postcssOptions) { if (typeof postcssProcessor === 'undefined') { postcssProcessor = createProcessor( postcssPluginsMap, postcssOptions ); } result = await postcssProcessor.process( result.css, Object.assign({}, postcssOptions.processor, { from: outfile, to: outfile, }) ); if (result.warnings) { const warnings = result.warnings(); if (warnings.length) { message(`Error processing ${label || `${filestem}.css`}`, 'warning'); warnings.forEach((warn) => { message(warn.toString()); }); } } } try { await writeFile(outfile, result.css).then(() => { // Purge CSS once file exists. if (outfile && purge) { purgeUnusedCSS(outfile, `${label || `${filestem}.css`}`); } }); if (result.css) { message(`${label || `${filestem}.css`} compiled`, 'success', timeLabel); } else { message(`${label || `${filestem}.css`} is empty`, 'notice', timeLabel); } } catch (err) { message(`Error compiling ${label || `${filestem}.css`}`, 'error'); message(err); notification({ title: `${label || `${filestem}.css`} save failed 🚨`, message: `Could not save stylesheet to ${label || `${filestem}.css`}` }); } if (result.map) { try { await writeFile(outfile + '.map', result.map.toString()); } catch (err) { message(`Error compiling ${label || `${filestem}.css.map`}`, 'error'); message(err); notification({ title: `${label || `${filestem}.css.map`} save failed 🚨`, message: `Could not save sourcemap to ${label || `${filestem}.css.map`}` }); } } } catch (err) { message(`Error compiling ${label || `${filestem}.scss`}`, 'error'); message(err.formatted || err); notification({ title: `${label || `${filestem}.scss`} compilation failed 🚨`, message: (err.formatted || `${err.name}: ${err.message}`) }); } }); }; /** * Purge unused styles from CSS files. * * @async * * @param {string} outfile - The path of a css file * If missing the function stops. * @param {string} label - The CSS file label or name. * @return {Promise} */ async function purgeUnusedCSS(outfile, label) { const contentFiles = loconfig.tasks.purgeCSS?.content; if (!Array.isArray(contentFiles) || !contentFiles.length) { return; } label = label ?? basename(outfile); const timeLabel = `${label} purged in`; console.time(timeLabel); const purgeCSSResults = await (new PurgeCSS()).purge({ content: contentFiles, css: [ outfile ], defaultExtractor: content => content.match(/[a-z0-9_\-\\\/\@]+/gi) || [], fontFaces: true, keyframes: true, safelist: { // Keep all except .u-gc-* | .u-margin-* | .u-padding-* standard: [ /^(?!.*\b(u-gc-|u-margin|u-padding)).*$/ ] }, variables: true, }) for (let result of purgeCSSResults) { await writeFile(outfile, result.css) message(`${label} purged`, 'chore', timeLabel); } }