WeAreGenki/minna-ui

View on GitHub
utils/build-css/src/index.ts

Summary

Maintainability
B
5 hrs
Test Coverage
/**
 * Tool to compile Minna UI CSS packages.
 */

/* eslint-disable security/detect-non-literal-fs-filename, security/detect-object-injection, no-restricted-syntax, id-length, no-console */

import CleanCSS from 'clean-css';
import fs from 'fs';
import mri from 'mri';
import { basename, dirname, join } from 'path';
import postcss from 'postcss';
import postcssrc from 'postcss-load-config';
import { promisify } from 'util';

const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const mkdir = promisify(fs.mkdir);
const readdir = promisify(fs.readdir);

const ARGS_START = 2;

/**
 * Print a list of warnings or errors to the process stderr.
 *
 * @param from - Which lib the warning came from.
 * @param level - The severity of either WARN or ERR.
 * @param warnings - List of warnings to iterate over.
 */
function warn(
  from: string,
  level: 'ERR' | 'WARN',
  warnings: string[] | postcss.Warning[],
): void {
  /* istanbul ignore if */
  if (warnings.length && level === 'ERR') {
    // prevent tests running too long
    process.exitCode = 1;
  }

  for (const err of warnings) {
    if (
      typeof err === 'string' &&
      err.startsWith('Ignoring local source map')
    ) {
      return;
    }

    console.warn(`[${from}] ${level}: ${err.toString()}`);
  }
}

interface ProcessCssOpts {
  banner?: string;
  from: string;
  optimize?: boolean;
  sourcemap?: boolean;
  to?: string;
}

interface ProcessCssResult {
  code: string;
  map?: string | { toString: () => string };
}

/**
 * Process CSS.
 *
 * @param opts - User defined options.
 * @param opts.banner - Banner to prepend to resulting code.
 * @param opts.from - File from.
 * @param opts.optimize - Minify resulting code.
 * @param opts.sourcemap - Generate sourcemap and add sourcemap URL to code.
 * @param opts.to - File to.
 */
async function processCss({
  banner = '',
  from,
  optimize = process.env.NODE_ENV === 'production',
  sourcemap,
  to = from,
}: ProcessCssOpts): Promise<ProcessCssResult> {
  const src = await readFile(from, 'utf8');

  const ctx = { from, map: sourcemap && { inline: false }, to };
  const source = banner + src;

  const { options, plugins } = await postcssrc(ctx, from);
  const result = await postcss(plugins).process(source, options);

  warn('PostCSS', 'WARN', result.warnings());

  let code = result.css;
  let map = sourcemap && result.map ? result.map.toString() : '';

  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  const filePath = options.to!;
  const dirPath = dirname(filePath);

  if (optimize) {
    // Minify resulting CSS
    const min = new CleanCSS({
      level: {
        1: { all: true },
        2: { all: true },
      },
      sourceMap: !!map,
    }).minify(code, map);

    warn('CleanCSS', 'ERR', min.errors);
    warn('CleanCSS', 'WARN', min.warnings);

    const fileName = basename(filePath);

    if (map) {
      // Add back source map comment because clean-css removes it
      code = `${min.styles}\n/*# sourceMappingURL=${fileName}.map */`;
      map = min.sourceMap;
    }
  }

  // Create output directory
  await mkdir(dirPath, { recursive: true });

  await writeFile(filePath, code);
  if (map) {
    await writeFile(`${filePath}.map`, map.toString());
  }

  return {
    code,
    map,
  };
}

/**
 * Run CSS build process.
 *
 * @param env - Node `process.env`.
 * @param argv - Node `process.argv`.
 */
export async function run(
  env: NodeJS.ProcessEnv,
  argv: string[] = [],
): Promise<ProcessCssResult[]> {
  const args = mri(argv.slice(ARGS_START), {
    alias: { b: 'banner', h: 'help', m: 'sourcemap' },
    boolean: ['banner', 'help', 'sourcemap'],
    default: { b: true, m: true },
  });
  const { help, banner: hasBanner, sourcemap } = args;

  if (help) {
    console.log(`
Build CSS. Typically zero additional configuration is required because your
package.json is the config.

USAGE:
  build-css [src] [dest] [options]

OPTIONS
  -h --help       Print this help message and exit.
  -b --banner     Inject banner text atop the output (default true).
  -m --sourcemap  Generate code source maps (default true).
`);
    // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
    // @ts-ignore - Easier to deal with a wrong return type here than possible nulls tests
    return;
  }

  process.env.NODE_ENV = env.NODE_ENV || 'production';
  const pkgName = env.npm_package_name;
  const pkgVersion = env.npm_package_version;
  const pkgHomepage = env.npm_package_homepage;
  const pkgStyle = env.npm_package_style;
  const pkgMain = env.npm_package_main;

  const cssBanner = `/*!
 * ${pkgName} v${pkgVersion} - ${pkgHomepage}
 * Copyright ${new Date().getFullYear()} We Are Genki
 * Apache 2.0 license - https://github.com/WeAreGenki/minna-ui/blob/master/LICENCE
 */
`;

  const inputDir = args._[0];
  const outputDir = args._[1];
  const cwd = process.cwd();
  const banner = hasBanner ? cssBanner : '';
  const inputCss: string[] = [];
  const outputCss: string[] = [];

  try {
    if (!inputDir) {
      if (!pkgStyle && !pkgMain) {
        throw new Error('No input file or directory specified!');
      }

      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      inputCss.push(pkgMain!);
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      outputCss.push(pkgStyle!);
    } else {
      if (!outputDir) throw new Error('No output directory specified!');

      const dirFiles = await readdir(inputDir);
      const cssFiles = dirFiles.filter(
        (fileName) =>
          fileName !== 'import.css' &&
          fileName.endsWith('.css') &&
          !fileName.startsWith('_'),
      );

      cssFiles.forEach((fileName) => {
        inputCss.push(join(cwd, inputDir, fileName));
        outputCss.push(join(cwd, outputDir, fileName));
      });
    }

    const results = [];

    for (let index = 0; index < inputCss.length; index += 1) {
      const from = inputCss[index];
      const to = outputCss[index];

      const result = processCss({ banner, from, sourcemap, to });
      results.push(result);
    }

    // Await here to capture any errors
    const allResults = await Promise.all(results);

    // eslint-disable-next-line consistent-return
    return allResults;
  } catch (err) {
    if (err.showSourceCode) {
      console.error(
        `[build-css] PostCSS error: ${err.message}:\n${err.showSourceCode()}`,
      );
    } else {
      console.error('[build-css]', err);
    }

    // We always want internal builds to fail on error
    process.exitCode = 2;
    throw err;
  }
}