I started this Grav Blog in 2016. It was a nice and easy way to get started and to set everything up. I got the LingonBerry theme and only changed a few lines in its CSS code to change some colors. This worked fine and was up for years.

Recently I wanted to get back to my blog to add some more content, and so I looked through the code that was in place. The theme just uses a plain CSS file and a plain JS file for its style and user interaction. This is a fine base but, over the last few years, I moved all my JavaScript to TypeScript and the CSS to SCSS. This makes the development a bit easier and more secure but as the browser does not understand it, it needs to be 'translated' back to CSS and JS.

Installing WebPack Encore

I got used to WebPack Encore taking care of this in my Symfony projects, so I started having a look at how to implement this in Grav. Reading Documentation I installed it with yarn :

yarn add @symfony/webpack-encore --dev

When installing this in Symfony projects with flex the configuration files will be generated but here this was not the case. So I added my own webpack.config.js file at the root of my project :

const Encore = require('@symfony/webpack-encore');
const path = require('path');
const CompressionPlugin = require('compression-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
        Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

const PATH = path.join('user', 'themes', 'lingonberry-custom')
const BUILD_PATH = path.join(PATH, 'build')

Encore
        // directory where compiled assets will be stored
        .setOutputPath(BUILD_PATH)
        // public path used by the web server to access the output path
        .setPublicPath(path.join('/', BUILD_PATH))

        .addEntry('app', path.join('./', PATH, 'js', 'app.ts'))

        .disableSingleRuntimeChunk()

        .cleanupOutputBeforeBuild()

        .enableSourceMaps(!Encore.isProduction())
        // enables hashed filenames (e.g. app.abc123.css)
        .enableVersioning(Encore.isProduction())

        .addPlugin(new CompressionPlugin())
        // .addPlugin(new MiniCssExtractPlugin())

        .addPlugin(new HtmlWebpackPlugin({
                inject: false,
                filename: 'assets.html',
                publicPath: path.join('/', BUILD_PATH),
                scriptLoading: 'defer',
                templateContent: ({htmlWebpackPlugin}) => `
                    ${htmlWebpackPlugin.tags.headTags}
                    `
                }
                ))

        // enables @babel/preset-env polyfills
        .configureBabelPresetEnv((config) => {
                config.useBuiltIns = 'usage';
                config.corejs = 3;
        })

        .enableSassLoader()
        .enableTypeScriptLoader()

module.exports = Encore.getWebpackConfig();

I imported some plugins for the SASS and TS compilation and a few more we will see after, then I added some variables :

const PATH = path.join('user', 'themes', 'lingonberry-custom')
const BUILD_PATH = path.join(PATH, 'build')

The theme for my website being located inside user/themes/lingonberry-custom I wanted to have the path of my theme in a variable in case I would ever change my theme it would make it easy to update my webpack.config to watch and build the correct files.

You need to tell Webpack where the files are that he needs to watch and where to put them once he has translated them. With Grav the outputPath and the publicPath are nearly identical as everything is in the same root directory (all the files that are served by the frontend server, as well as, the php files needed for the backend part).

        .setOutputPath(BUILD_PATH)
        .setPublicPath(path.join('/', BUILD_PATH))
        .addEntry('app', path.join('./', PATH, 'js', 'app.ts'))

So I added a app.ts file in my user/themes/lingonberry-custom/js/ directory which will end up as user/themes/lingonberry-custom/build/app.js

Building the assets

Once the main configuration set up we need to have a way to define and run the scripts needed to watch the files we will change and build the output files. When running the yarn add command earlier this should have created a package.json at the root of your project :

{
  "devDependencies": {
    "@symfony/webpack-encore": "^1.1.2"
  }
}

This will not be enough to get everything to run correctly, I added some devDependencies needed for the whole SCSS and TypeScript part of this, as well as some scripts to make it easier to run the builds :

{
  "devDependencies": {
    "@symfony/webpack-encore": "^1.1.2",
    "@types/highlightjs": "^10.1.0",
    "compression-webpack-plugin": "^7.1.2",
    "core-js": "^3.9.1",
    "file-loader": "^6.0.0",
    "html-webpack-plugin": "^5.3.1",
    "sass": "^1.32.8",
    "sass-loader": "^11.0.0",
    "ts-loader": "^8.0.1",
    "typescript": "^4.2.3"
  },
  "scripts": {
    "dev-server": "encore dev-server",
    "dev": "encore dev",
    "watch": "encore dev --watch",
    "build": "encore production --progress"
  }
}

When updating the package.json make sure you run yarn install to get the new dependencies, if not the build will fail. If you have yarn running on your computer you can now run yarn watch in your development environment and at every change in your app.ts file the outputs will be rebuilt automatically. I tend to run my development environment inside a Docker container that I define inside a docker-compose.yaml file at the root of my project. here is the part needed to run all the WebPack functions :

version: '3.7'

services:
  node:
    image: node:14-alpine
    user: node
    working_dir:
      /srv/app/public
    volumes:
      - ./:/srv/app/public
    command: >
      sh -c "cd /srv/app/public
      && yarn
      && yarn watch"

If I run docker-compose up -d it will watch the files and rebuild them.

Adding some SCSS

I now have a TypeScript file being automatically translated to JavaScript, but I also want some SCSS being translated to CSS. This is pretty simple as it has been enabled by adding .enableSassLoader() in our webpack.config.js. I can now add a file in my user/lingonberry-custom/css directory called custom.scss and another one called _variables.scss :

//_variables.scss
$bg-color: #f1f1f1;
$primary-color: #ff6a1e;

//custom.scss
@use 'variables' as *;

body {
  a {
    color: $primary-color;
    }
}

Now to make sure WebPack will watch and build these files I have to import them in my app.ts file :

import '../css/custom.scss'

This will generate an app.css file in the build directory inside my theme.

Import the files inside the theme HTML

This was one of the most complicated parts, I was used to running this with twig as the following :

{% block stylesheets %}
    {{ encore_entry_link_tags('app') }}
{% endblock %}

{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}

I do not have this available here so I turned to the HtmlWebpackPlugin to generate an HTML file containing all the link and script elements I will need to get my stuff to be usable in the theme. This is where this part of the webpack.config.js comes in :

    .addPlugin(new HtmlWebpackPlugin({
            inject: false,
            filename: 'assets.html',
            publicPath: path.join('/', BUILD_PATH),
            scriptLoading: 'defer',
            templateContent: ({htmlWebpackPlugin}) => `
                ${htmlWebpackPlugin.tags.headTags}
                `

This looks a bit more complicated than the rest, and, it is. By default, the HtmlWebpackPlugin will generate a basic html page with the main tags like html, body, title, ... My theme base already contains all of this and all I want is to find a way to include my built JS and CSS assets, not rebuild the whole theme page. This is why I changed the following options:

  • inject : false // I do not want to inject some HTML inside a file I want to generate a file with just the links I will need
  • filename : 'assets.html' // Set the name of the HTML file that will be generated
  • publicPath : path.join('/', BUILD_PATH) // Tell webpack how to generate the path of the built files inside the HTML file
  • scriptLoading : 'defer' // To make things easier I want my CSS and JS files I one place, but I also want JS to wait until the page is fully loaded
  • templateContent : ({htmlWebpackPlugin}) => ${htmlWebpackPlugin.tags.headTags} })) // This tells the plugin how to generate the content. I do not want it to modify an existing file, so I pass it the content in which to inject the generated code. It contains nothing, but the variable that will be replaced by the link and script tags.

    Now when running the build a file will be generated inside the build directory withing my theme :

<!-- user/themes/lingonberry-custom/build/assets.html -->
 <script defer src="/user/themes/lingonberry-custom/build/app.js"></script><link href="/user/themes/lingonberry-custom/build/app.css" rel="stylesheet">

Getting closer, now I needed to find a way to incorporate this inside the base template of my theme. By default, Twig would not try to look inside this build directory, only in the templates directory of my theme. So I added this directory to the twig directories by creating / editing (depending on your theme) the PHP file in the theme root directory, lingonberry-custom.php :

<?php
namespace Grav\Theme;

use Grav\Common\Theme;
use Grav\Common\Grav;

class LingonberryCustom extends Theme
{
    public static function getSubscribedEvents() {
        return [
            'onTwigLoader' => ['onTwigLoader', 10]
        ];
    }

    public function onTwigLoader() {
        $themePath = Grav::instance()['locator']->findResource('themes://lingonberry-custom');
        $this->grav['twig']->addPath($themePath . DIRECTORY_SEPARATOR . 'build');
    }
}

Telling Twig to add a path to the build directory. We can now tell Twig to include this page in it's base template :

<head>
    {% include 'assets.html' %}
</head>

Awesome this now all works locally for me, but I am also going to need to optimize the build outputs in production and make sure they are used. If you tend to build locally and upload the changed files to your server you can run yarn encore production and upload the changed files to your production server. When running this command the CSS and JS files will change name on every change. This is so that you can add the best cache configuration to your frontend server. Meaning that a visitor that comes back to your site will not have to download one of the JS or CSS files if they have not changed since the last time you have modified them. I will soon make another post about configuring nGinx cache policies.

This means that on every production build files with new names will be created, something like app.400c9c83.js. This is why I run the builds on a private gitlab instance with runners that will rsync --delete on the production server. If you ever want to know more about this whole CI/CD stuff let me know, and I'll make an article about it ;)