WeAreGenki/minna-ui

View on GitHub
utils/rollup-plugin-emit-html/src/index.ts

Summary

Maintainability
B
5 hrs
Test Coverage
// TODO: Add support for CSS source maps

/* eslint-disable security/detect-object-injection */

import CleanCSS from 'clean-css';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import rollup from 'rollup';
import { createFilter } from 'rollup-pluginutils';

interface EmitHtmlOptions {
  /** Path prefix for files in URLs (in script.src etc.). */
  basePath?: string;
  /**
   * Document HTML content. `%CSS%` and `%JS%` will be replaced with tags
   * referencing the files.
   */
  content?: string | Promise<string>;
  /** Files to exclude from CSS processing. */
  exclude?: RegExp[] | string[];
  /** Files to include in CSS processing. */
  include?: RegExp[] | string[];
  /** Inline CSS code instead of emiting to seperate file. */
  inlineCss?: boolean;
  /**
   * Perform output code optimisations (e.g. CSS minification). You can
   * optionally use an object to pass through options to the optimizer/s.
   */
  optimize?: boolean | CleanCSS.OptionsOutput;
  /** Attribute/s to add to script tag. */
  scriptAttr?: string;
  /** Path to a HTML document template file or the template as a string. */
  template?: string;
  /** Document title. */
  title?: string;
  /** Any other data you want available in the template. */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  data?: any[];
}

/**
 * Ultra-minimal template engine.
 *
 * @see https://github.com/Drulac/template-literal
 *
 * @param template - A HTML template to compile.
 */
export function compileTemplate(template: string): Function {
  // eslint-disable-next-line
  return new Function('d', 'return `' + template + '`');
}

/**
 * Emit HTML Rollup plugin.
 * Generates HTML from a template, injects entry scripts, and combines CSS,
 * optionally inlining it. Emits all assets back to Rollup for futher
 * processing or to save to disk. The asset names will be based on
 * `rollup#output.name` if defined otherwise they'll be the same base name as
 * the first `.js` bundle file.
 */
export function emitHtml({
  basePath = '',
  content = '%CSS%\n%JS%',
  exclude,
  include = [/\.(p|post)?css$/],
  inlineCss = false,
  optimize = process.env.NODE_ENV === 'production',
  scriptAttr = 'defer',
  template = join(__dirname, '../src/template.html'),
  title,
  ...data
}: EmitHtmlOptions = {}): rollup.Plugin {
  const filter = createFilter(include, exclude);
  const styles: { [id: string]: string } = {};
  let htmlTemplate = template;

  return {
    name: 'emit-html',

    transform(code, id) {
      if (!filter(id)) return;

      // When `template` is a file path which exists use its content as the
      // template otherwise we assume `template` is the actual template itself
      if (existsSync(template)) {
        // Read template file every time in case it has changed between builds
        htmlTemplate = readFileSync(template, 'utf8');
        this.addWatchFile(template);
      }

      styles[id] = code;

      return ''; // eslint-disable-line consistent-return
    },

    // eslint-disable-next-line sort-keys
    async generateBundle(outputOpts, bundle) {
      const minifyCss = (css: string): string => {
        const cleancss = new CleanCSS({
          sourceMap: false, // TODO: Add source map support
          ...(typeof optimize === 'object' ? optimize : {}),
        });

        const result = cleancss.minify(css);

        result.warnings.forEach((err) => this.warn(err));
        result.errors.forEach((err) => this.error(err));

        return result.styles;
      };

      try {
        // combine all style sheets
        let css = '';
        // eslint-disable-next-line
        for (const id in styles) {
          css += styles[id] || '';
        }

        if (optimize) {
          css = minifyCss(css);
        }

        const jsFiles = Object.values(bundle).filter(
          // @ts-ignore FIXME: Work how to best work with discriminating unions, ideally without type casting
          (chunk) => chunk.isEntry && chunk.fileName.endsWith('.js'),
        );

        const scripts: string[] = [];

        jsFiles.forEach(({ fileName }) => {
          scripts.push(
            `<script src=${basePath}${fileName} ${scriptAttr}></script>`,
          );
        });

        const name =
          outputOpts.name || jsFiles[0].fileName.replace(/\.js$/, '');
        const cssFile = `${name}.css`;
        const htmlFile = `${name}.html`;

        // eslint-disable-next-line no-nested-ternary
        const cssResult = !css.length
          ? ''
          : inlineCss
          ? `<style>${css}</style>`
          : `<link href=${basePath}${cssFile} rel=stylesheet>`;

        let body = await Promise.resolve(content);
        body = body.replace('%CSS%', cssResult);
        body = body.replace('%JS%', scripts.join('\n'));

        const html = compileTemplate(htmlTemplate)({
          content: body,
          title,
          ...data,
        }).trim();

        if (!inlineCss) {
          this.emitFile({
            name: cssFile,
            source: css,
            type: 'asset',
          });
        }

        this.emitFile({
          name: htmlFile,
          source: html,
          type: 'asset',
        });
      } catch (err) {
        this.error(err);
      }
    },
  };
}

export default emitHtml;