Our use case

It has been close to 3 years since Drupal 8 was released. With lots of amazing new features released in every 6 months we are getting more and more excited to see how Drupal has evolved and improved. With the release of Drupal 8.4, it is starting to use ES6 in core. However, there is not yet a standard practice to write ES6 in custom modules and themes.

The ideal solution is for compiled JS to still stay in the original module or theme and make our output JS still be compatible with Drupal 8. Drupal is heavily modular, so Javascript files written for a module should ideally be stored with it. This atomic design pattern allows a module to easily be enabled or uninstalled, and makes our code clean. JS files could be also added in a theme level if they are more related to that theme.

In this blog we will share our story on how to configure the project with webpack and other tools to facilitate ES6 in the whole Drupal 8 project including both custom modules and at the theme level.

Our solution

We are assuming you have sound knowledge of Drupal, Linux, Bash and PHP tools like Composer. In this article we will setup a Drupal example website in our local machine with  Drupal 8.6.0, Drush 9.4.0.

Drupal site installed on your local machine

Assuming your local site is up and running. You could also check the tip “Install on your machine” in the end of the post.

Setup frontend project

Create .nvmrc file

echo 8.10.0 .nvmrc
npm init
{
 "name": "drupal-project",
 "version": "1.0.0",
 "description": "[![Build Status](https://travis-ci.org/drupal-composer/drupal-project.svg?branch=8.x)](https://travis-ci.org/drupal-composer/drupal-project)",
 "main": "index.js",
 "scripts": {
   "test": "echo \"Error: no test specified\" && exit 1"
 },
 "author": "",
 "license": "ISC"
}

Install the dev dependency

npm i -D babel-core babel-eslint babel-loader babel-polyfill @babel/preset-env eslint eslint-config-airbnb eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react glob stylelint stylelint-checkstyle-formatter stylelint-config-standard stylelint-no-browser-hacks webpack webpack-cli

Half of the dev dependencies are copied and used in the Drupal core. With the similar settings we could leverage the eslint config files shipped in the Drupal core.

Webpack Settings

Webpack is static bundler tool for modern JavaScript application.

Create the webpack.config.js file

const path = require('path');
const glob = require('glob');

const rootDir = path.resolve(__dirname);

// ES6 JS in the custom Drupal modules.
const modulePath = path.resolve(__dirname, 'web/modules/custom');
const matchesInModules = glob.sync(`${modulePath}/*/js/*.es6.js`);

// ES6 JS in the custom theme.
const themePath = path.resolve(__dirname, 'web/themes/custom/mytheme');
const themeSrcPath = path.resolve(__dirname, `${themePath}/src/js`);
const themeDistPath = path.resolve(__dirname, `${themePath}/assets/js`);
const matchesInTheme = glob.sync(`${themeSrcPath}/*.js`);

const entry = {};

const allMatches = matchesInModules.concat(matchesInTheme);

allMatches.forEach((match) => {
 entry[match] = match;
});

/**
* Gets the file path of the compiled JS in the Drupal module.
*
* The compiled module JS files stay in the same directory of source js files.
* The new path will be generated based on /path/to/module/js/*.es6.js, and will
* be /path/to/module/js/*.js.
*
* @param filename
* @returns {string}
*/
const moduleJsOutputPath = (filePath) => {
 if (filePath.slice(-7) === '.es6.js') {
   const fileName = path.basename(filePath);
   const fileDir = path.dirname(filePath);
   const relativeDir = path.relative(rootDir, fileDir);
   // Remove .es6.js file extension.
   const newFileName = fileName.slice(0, -7);
   const newFilePath = `${relativeDir}/${newFileName}.js`;
   return newFilePath;
 }
};

/**
* Get the file path of the compiled JS in the Drupal theme.
*
* The compiled theme JS files stay in the assets/js directory.
*
* @param filePath
* @returns {string}
*/
const themeJsOutputPath = (filePath) => {
 const fileName = path.basename(filePath);
 const relativeDir = path.relative(rootDir, themeDistPath);
 const newFilePath = `${relativeDir}/${fileName}`;
 return newFilePath;
};

module.exports = {
 entry: entry,
 output: {
   path: rootDir,
   filename(object) {
     const filePath = object.chunk.name;
     let newFilePath;

     if (filePath.indexOf(themePath) !== -1) {
       newFilePath = themeJsOutputPath(filePath);
     }
     else {
       newFilePath = moduleJsOutputPath(filePath);
     }
     return newFilePath;
   },
 },
 devtool: 'hidden-source-map',
 externals: {
   jquery: 'jQuery',
   Drupal: 'Drupal',
   drupalSettings: 'drupalSettings',
 },
 module: {
   rules: [
     {
       test: /\.es6\.js/,
       include: [new RegExp(modulePath)],
       loader: 'babel-loader',
     },
     {
       test: /\.js/,
       include: [new RegExp(themeSrcPath)],
       loader: 'babel-loader',
     },
   ],
 },
 resolve: {
   alias: {
     _shared: path.resolve(themeSrcPath, 'shared')
   },
 }
};

Once we configure the webpack, we could add it to the npm task in package.json as below

"scripts": {
  "build:js": "webpack --config webpack.config.js -p",
  "build:js-dev": "webpack --config webpack.config.js --mode development --devtool source-map",
}

Here is the complete version of the package.json file

{
 "name": "drupal-project",
 "version": "1.0.0",
 "description": "[![Build Status](https://travis-ci.org/drupal-composer/drupal-project.svg?branch=8.x)](https://travis-ci.org/drupal-composer/drupal-project)",
 "main": "index.js",
 "scripts": {
   "build:js": "webpack --config webpack.config.js -p",
   "build:js-dev": "webpack --config webpack.config.js --mode development --devtool source-map",
   "test": "echo \"Error: no test specified\" && exit 1"
 },
 "author": "",
 "license": "ISC",
 "devDependencies": {
   "@babel/preset-env": "^7.1.0",
   "babel-core": "^6.26.3",
   "babel-eslint": "^9.0.0",
   "babel-loader": "^8.0.2",
   "babel-polyfill": "^6.26.0",
   "eslint": "^5.5.0",
   "eslint-config-airbnb": "^17.1.0",
   "eslint-plugin-import": "^2.14.0",
   "eslint-plugin-jsx-a11y": "^6.1.1",
   "eslint-plugin-react": "^7.11.1",
   "glob": "^7.1.3",
   "stylelint": "^9.5.0",
   "stylelint-checkstyle-formatter": "^0.1.2",
   "stylelint-config-standard": "^18.2.0",
   "stylelint-no-browser-hacks": "^1.2.1",
   "webpack": "^4.18.0",
   "webpack-cli": "^3.1.0"
 }
}

Babel

Babel is a JavaScript compiler. It compiles ES6 JavaScript to the old version that is compatible with old browsers. Create a Babel preset file and put which versions of browser your website will support.

Create .babelrc in the project root directory

{
 "presets": [
   [
     "@babel/preset-env",
     {
       "targets": "> 0.25%, not dead",
       "debug": true
     }
   ]
 ]
}

ESLint

ESLint is JavaScript linting utility. It is used to check our custom JavaScript files in the custom modules and themes compared to the JavaScript coding standards. Here we just leverage the default eslint settings shipped in Drupal core.

Create .eslintrc.json in the project root directory

{
 "extends": "./docroot/core/.eslintrc.json",
}

Beyond, you can configure the settings to fit your project unique requirements like including the more global libraries. For example we could include a global library called CookieMonster.

{
 "extends": "./docroot/core/.eslintrc.json",
 "globals": {
   "CookieMonster": true
 }
}

Once this is set we could use CookieMonster directly without using ‘import CookieMonster from CookieMonster“ in our ES6 scripts.

Create .eslintignore file

echo "webpack.config.js" > .eslintignore

Ignoring the webpack.config.js file and other JavaScript files which you don’t want ESLint to them, as no-ES6 code is used in webpack.config.js and ESLint will make complains about it.

Write ES6 in Drupal

Now we are free to use ES6 in both Drupal custom modules and themes by following a couple of conventions. In the webpack.config.js, we have setup the source and output directory of our ES6 scripts. However they are slightly different in the modules and themes.

The rule of thumb is we want to retain a Drupal native way to interact with JavaScript by using libraries. This means the compiled JS will be output into the original directory next to the source JS. To differentiate the source and output JS file, we will use extension ‘.es6.js’ to the ES6 JS and ‘.js’ to the output one. This idea is borrowed from Drupal core. For example when we create a new JS in the module, the file could be called mymodule/js/mymodule.es6.js and the compiled file will be mymodule/js/mymodule.js

The theme JS convention is slightly different from that JS in the modules because we get more flexibility to write JS in the theme. Instead of using the .es6.js extension, we could use different directories for source and output JS files. In this example, the source directory is configured as mytheme/src/js and the output directory is mytheme/assets/js. This means we could use same file name for both source and output versions. The rationale behind this is that it is more likely to write more complex JS in the theme and we could spit code to separate JS files for better architecture. In the example below we will create a JS in mytheme/src/js/mytheme.js and compiled file will be found in the  mytheme/assets/js/mytheme.js.

Alright, it’s demo time. In the following section I will create a custom module and a custom theme in Drupal to demonstrate how to use ES6 script in Drupal. When we enable this module and set the example theme as default, we could see both JS in the module and theme output a hello message in the browser console.

Create a custom theme

In the repo let’s create an example module called “mytheme” in the directory web/themes/custom

mytheme/mytheme.info.yml

name: My example theme
type: theme
description: This is a sub theme of Bartik
core: 8.x
base theme: bartik
libraries:
  - mytheme/global-styling

mytheme/mytheme.libraries.yml

global-styling:
  js:
    assets/js/mytheme.js: {}
  dependencies:
    - core/jquery
    - core/drupal

mytheme/src/js/mytheme.js

import HelloComponent from '_shared/HelloComponent';
(function ($, Drupal) {
 Drupal.behaviors.mytheme = {
   attach(context) {
     if (context === document) {
       const component = new HelloComponent('mytheme');
       component.sayHello();
     }
   }
 }

}(jQuery, Drupal));

In this example we create a path alias in webpack called ‘_shared’, this makes it easy to import JS in both theme and module scripts.

mytheme/src/js/shared/hello.component.js

export default class HelloComponent {
 constructor(name) {
   this.name = name
 }

 sayHello() {
   console.log(`Hello from ${this.name}.`);
 }
}

We could take advantage of import feature in ES6 to build small blocks.

Create a custom module

In the repo I will create a example module called “mymodule” in the directory web/modules/custom

mymodule/mymodule.info.yml

name: mymodule
type: module
description: My example module
core: 8.x

Define the info of this example module

mymodule/mymodule.libraries.yml

example:
  js:
    js/mymodule.js: {}
  dependencies:
    - core/jquery
    - core/drupal

Define a example library which will load a compiled mymodule.js file.

mymodule/js/mymodule.es6.js

import HelloComponent from '_shared/HelloComponent';

(function ($, Drupal) {
 Drupal.behaviors.mymodule = {
   attach(context) {
     if (context === document) {
       const component = new HelloComponent('mymodule');
       component.sayHello();
     }
   }
 }

}(jQuery, Drupal));

In this JS file, we import a custom component by using path alias defined in the Webpack and log hello message in the browser.

mymodule/mymodule.module

<?php
/**
* @file
* Load example js in every page.
*/

/**
* Implements hook_page_attachments().
*/
function mymodule_page_attachments(array &$attachments) {
 $attachments['#attached']['library'][] = 'mymodule/example';
}

We load module JS in every page.

Test

Build JS

# Switch to the right node version
nvm use

# Build JS for the development mode
npm run build:js-dev

# Build JS for the production mode
npm run build:js

After the build task is completed, you should see the compiled JS file in green colour in the terminal.

Enable the custom module

drush pm:enable mymodule –yes
[/bash]

Enable the custom theme and set it as default theme

drush config:set system.theme default mytheme --yes
[/bash]
Disable JS aggregation for testing

drush config:set system.performance js.preprocess 0 --yes
[/bash]

Open the Drupal website in Chrome with developer console opened, refresh the page

image3

Voila, now you see those files are loaded in the browser and working now.

What’s next?

We use this approach in our project and we see benefits when the JS code is growing bigger and bigger. By configuring the Webpack, we could include more ES6 JS in other locations, for example writing CKEditor plugin with ES6 style. Of course we could also bundle SCSS files by using Webpack.

There are more things we could do to modernize the JavaScript framework in Drupal. One thing could be using TypeScript to enforce strong typed programming. Another thing would be exploring how to write JS in a component way with or without using those modern JS frameworks like Angular, React or VueJS. We will try those things and share our experiences in the future.

Summary

In this article, we have setup ESLint, Webpack, babel to support and compile ES6 scripts in both custom module and theme, so  ES6 JS source can be compiled to old browser compatible version. Later we create a custom module and theme and show you the examples of how to add ES6 scripts to those places. Once both module and theme have been enabled and used as default, we should see the hello message in the Chrome browser console.

Tips

Install Drupal on your machine

Preparation

Before starting, get the following tools ready on your local machine:

Assuming:

  • you have created a virtual host in your MAMP
  • your MySQL root user is root and password is root
  • you have create a database in MySQL database called drupal-project

Use a composer template to create a new Drupal project

composer create-project drupal-composer/drupal-project:8.x-dev . --no-interaction

Once this is completed, we need to sort out the MySQL DB connection. Create a settings.php page by

cd web/sites/default
cp default.settings.php settings.php

Open the settings.php file and config the database settings as below

$databases['default']['default'] = array (
 'database' => 'drupal-project',
 'username' => 'root',
 'password' => 'root',
 'prefix' => '',
 'host' => '127.0.0.1',
 'port' => '3306',
 'namespace' => 'Drupal\\Core\\Database\\Driver\\mysql',
 'driver' => 'mysql',
);

Install the Drupal site

drush site:install

Git ignore

If you also use git in the project, you need add some git ignore rules. As the compiled JS could be built in the CI, we could git ignore them as well as node_modules.

Add the following lines into .gitignore

/node_modules/
/web/modules/custom/*/js/*.js
!/web/modules/custom/*/js/*.es6.js
/web/theme/custom/mytheme/assets/js

Thank you to all contributors and reviewers to encourage and help me write this blog:D

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s