Untitled

mail@pastecode.io avatarunknown
javascript
a month ago
9.9 kB
1
Indexable
Never

import autoprefixer from 'autoprefixer';
import chokidar from 'chokidar';
import copy from 'rollup-plugin-copy';
import del from 'rollup-plugin-delete';
import eslint from '@rollup/plugin-eslint';
import fs from 'fs';
import glob from 'glob';
import { nodeResolve as resolve } from '@rollup/plugin-node-resolve';
import read from 'read-file';
import * as rollup from 'rollup';
import styles from 'rollup-plugin-styles';
import { terser } from 'rollup-plugin-terser';

/*
 * Config
 * files: will create a new chunk
 * modules: wil not create a new chunk and cause a full rebuild
*/
const config = {
  mode: process.argv.includes('--watch') ? 'development' : 'production',
  notify: true,
  dest: './theme/assets',
  assets: 'dev/assets/*',
  js: {
    files: [
      './dev/js/*.js',
      './dev/js/ext/*.js',
    ],
    modules: [
      './dev/js/modules/*.js',
    ],
  },
  css: {
    files: [
      './dev/scss/critical.scss',
      './dev/scss/component/**/*.scss',
      './dev/scss/section/**/*.scss',
      './dev/scss/template/**/*.scss',
    ],
    modules: [
      './dev/scss/ext/**/*.scss',
      './dev/scss/mixins/**/*.scss',
      './dev/scss/partials/**/*.scss',
    ],
  },
  critical: {
    input: 'critical', // without extension
    inputFull: './dev/scss/critical.scss',
    dest: './theme/snippets',
    rename: 'critical-css.liquid',
  },
};

/*
 * Define Rollup, Eslint, Terser & scss options
*/
const eslintOptions = {
  fix: true,
  cache: true,
  throwOnError: false,
  throwOnWarning: false,
  include: [
    ...config.js.files,
    ...config.js.modules,
  ],
  exclude: [
    './node_modules/**/*',
  ],
};

const terserOptions = {
  ecma: '2016',
  mangle: false,
  compress: false,
  toplevel: false,
};

const scssOptions = {
  mode: 'extract',
  plugins: [
    [ autoprefixer, {remove: false} ],
    [ 'postcss-preset-env' ],
  ],
  minimize: true,
  url: false,
  include: config.css.files,
  exclude: [],
  use: ['sass'],
};

const inputOptions = {
  input: globify(config.js.files),
  cache: true,
  treeshake: false,
  watch: false,
  plugins: [
    resolve(),
  ],
};

const outputOptions = {
  dir: config.dest,
  format: 'es',
  sourcemap: true,
  assetFileNames: '[name][extname]',
  entryFileNames: '[name].js',
  chunkFileNames: '[name]-[hash].async.js',
  globals: {
    jquery: '$',
  },
  plugins: [],
};

const options = {
  ...inputOptions,
  output: outputOptions,
};

/*
 * Notify
 * @param message {string}: your console message
*/
function notify(message) {
  if (!message || !config.notify) {
    return false;
  }
  console.log(`\x1b[36m ${message}`);
}

/*
 * Globify an array
 * @param array {array}: array with globs
*/
function globify(array = []) {
  if (!Array.isArray(array) || !array[0]) {
    return array;
  }
  return array.map((item) => {
    return glob.sync(item);
  }).flat();
}

/*
 * Check if file is used in critical css
 * @param file {string}: path to file
*/
function usedInCriticalCss(file) {
  const criticalContent = read.sync(config.critical.inputFull, 'utf8');
  file = file.split('/').pop().replace('.scss', '').replace('.css', '');
  return !!criticalContent.includes(file);
}

/*
 * Rollup plugin
 * Notify file updates in the terminal
 * @param type {string}: file extension (.js/.css)
 */
function notifyUpdates(type) {
  return {
    name: 'notify-updates',
    writeBundle(options, bundle) {
      // Show updated file notifications
      for (let filename in bundle) {
        let file = `${config.dest}/${filename}`;
        if (!filename.includes('.old.js')) {
          notify(`Updated: ${file}`);
        }
      }

      // JS file will trigger a full reload
      if (type == 'JS') {
        notify('Trigger reload...');
      }
    },
  };
}

/*
 * Bundle function.
 * @param type {string}: 'CSS' or 'JS'
 * @param inputOptions {object}: object with rollup input options
 * @param outOptions {object}: object with rollup output options
*/
let cleanedUp = false;
async function bundle(type, inputOptions, outputOptions) {
  if (!inputOptions || !outputOptions) {
    return false;
  }

  // Clean up assets folder if command uses --all and not cleaned up yet.
  if (!cleanedUp && config.mode == 'production' && process.argv.includes('--all')) {
    cleanedUp = true;
    inputOptions.plugins = [
      del({
        targets: `${config.dest}/**/*`,
      }),
      copy({
        targets: [
          { src: config.assets, dest: config.dest },
        ],
      }),
      ...inputOptions.plugins,
    ];
    notify('Cleaned up assets');
    notify('Copied assets');
  }

  // Add touch file in asset folder to tricker shopify-cli
  if (config.mode == 'development') {
    outputOptions.plugins = [
      ...outputOptions.plugins,
      notifyUpdates(type),
    ];
  }

  // Notify when build starts
  notify(`Bundling ${type} assets...`);

  // Catch error when bundling fails
  try {
    // Create a bundle
    const bundle = await rollup.rollup(inputOptions);

    // Write the bundle to disk
    await bundle.write(outputOptions);

    // Close the bundle
    await bundle.close();

    // Notify when build is done
    notify(`Built ${type} assets`);
  } catch (err) {
    // Log CSS/JS error
    notify(err);
  }

}

/*
 * Process and optimise Javascript.
 * @param input {string / array}: input files or array.
*/
async function processJs(input) {
  if (!input) {
    return false;
  }

  // Copy default options
  let _inputOptions = { ...inputOptions };
  let _outputOptions = { ...outputOptions };

  // Update options
  _inputOptions.input = input;
  _inputOptions.plugins = [
    ..._inputOptions.plugins,
    eslint(eslintOptions),
    terser(terserOptions),
  ];

  // Create the bundle
  await bundle('JS', _inputOptions, _outputOptions);
}

/*
 * Process and optimise CSS.
 * @param input {string / array}: input files or array.
*/
async function processCss(input) {
  if (!input) {
    return false;
  }

  // Check if critical css needs to be generated.
  let processCritical = false;
  if (typeof input == 'string' ) {
    processCritical = !!input.includes(config.critical.input);
  } else if(Array.isArray(input)) {
    processCritical = input.map((file) => !!file.includes(config.critical.input)).filter(Boolean)[0];
  }

  // Copy default options
  let _inputOptions = { ...inputOptions };
  let _outputOptions = { ...outputOptions };

  // Update options
  _inputOptions.input = input;
  _inputOptions.plugins = [
    ..._inputOptions.plugins,
    styles(scssOptions),
    // CSS bundles will also create a JS file. Clean this up.
    del({
      hook: 'closeBundle',
      targets: `${config.dest}/*.old.js`,
    }),
    // Create critical inline css
    processCritical && copy({
      hook: 'closeBundle',
      targets: [{
        src: `${config.dest}/${config.critical.input}.css`,
        dest: config.critical.dest,
        rename: () => config.critical.rename,
        transform: (contents) => {
          return `{% style %}${contents.toString()}{% endstyle %}`;
        },
      }],
    }),
  ];

  // Update output plugins
  _outputOptions.sourcemap = false,
  _outputOptions.entryFileNames = '[name].old.js',
  _outputOptions.chunkFileNames ='[name]-[hash].old.js',

  // Create the bundle
  await bundle('CSS', _inputOptions, _outputOptions);
}

/*
 * Create watcher
*/
class Watcher {
  constructor() {
    this.javascript();
    this.css();
    this.assets();
    this.freeze = false;
    this.timer = (ms = 1000) => {
      this.freeze = true;
      setTimeout(() => {
        this.freeze = false;
      }, ms);
    };
  }

  /*
   * Watch javascript files
  */
  javascript() {

    // Watch input files and process single input file again.
    chokidar.watch(config.js.files).on('change', (file) => {
      if (this.freeze) return false;
      this.timer();
      notify(`Processing: ${file}...`);
      processJs(file);
    });

    // Watch module files and process all input files again.
    chokidar.watch(config.js.modules).on('change', (file) => {
      if (this.freeze) return false;
      this.timer();
      notify('Processing: all js...');
      processJs(globify(config.js.files), 'fallback');
    });
  }

  /*
   * Watch CSS files
  */
  css() {
    // Watch input files and process single input file again.
    chokidar.watch(config.css.files).on('change', (file) => {
      if (this.freeze) return false;
      this.timer();
      notify(`Processing: ${file}...`);
      processCss(file);
      // If used in critical css, update critical css
      if(usedInCriticalCss(file)) {
        notify(`Processing: ${config.critical.inputFull}...`);
        processCss(config.critical.inputFull, '.css');
      }
    });

    // Watch input files and process single input file again.
    chokidar.watch(config.css.modules).on('change', (file) => {
      if (this.freeze) return false;
      this.timer();
      notify('Processing: all css...');
      processCss(globify(config.css.files), 'fallback');
    });
  }

  /*
   * Watch assets folder
  */
  assets() {
    // Watch input files and process asset to theme asset folder
    chokidar.watch(config.assets).on('change', (file) => {
      if (this.freeze) return false;
      this.timer();
      let destination = file.replace('dev/assets/', 'theme/assets/');
      fs.copyFile( file, destination, null, (err) => {
        if (err) {
          console.error(err);
          return;
        }
        notify(`Updated asset: ${destination}...`);
      });
    });
  }

}

/*
 * Initialize
*/
if (config.mode == 'development') {
  // If development mode only watch files
  const watcher = new Watcher();

} else {
  // If production mode build assets
  switch(true) {
  case process.argv.includes('--js'):
    processJs(globify(config.js.files));
    break;
  case process.argv.includes('--css'):
    processCss(globify(config.css.files));
    break;
  default:
    processJs(globify(config.js.files));
    processCss(globify(config.css.files));
  }
}