As I have added TypeScript and WebPack encore to Grav recently I wanted to find a way to add CSS and JavaScript only to certain pages.

Generating multiple outputs for assets

So far I generated a assets.html page that would contain the links to the JavaScript and CSS files generated by WebPack :

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

To generate different files I changed the webpack.config.js file. I found most of the information in the Symfony documentation.

However, as I do not really like repeating myself I created a function :

function generateBaseConfig(name, buildPath, inputFile, outputFile, cleanOutput = true) {
        let itemBuildPath = path.join(BUILD_PATH_BASE, name);
        if (buildPath) {
                itemBuildPath = path.join(BUILD_PATH_BASE, buildPath);
        }

        if (!inputFile) {
                inputFile = `${name}.ts`
        }

        if (!outputFile) {
                outputFile = 'assets.html'
        }

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

                .addEntry(name, `./${path.join(PATH, 'js', inputFile)}`)

                // When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
                //.splitEntryChunks()

                // will require an extra script tag for runtime.js
                // but, you probably want this, unless you're building a single-page app
                //.enableSingleRuntimeChunk()
                .disableSingleRuntimeChunk()

                .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: outputFile,
                        publicPath: path.join('/', itemBuildPath),
                        scriptLoading: 'defer',
                        templateContent: ({htmlWebpackPlugin}) => `
        ${htmlWebpackPlugin.tags.headTags}
        `
                }))

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

                .enableSassLoader()

                .enableTypeScriptLoader();

        if (cleanOutput) {
                config.cleanupOutputBeforeBuild()
        }
        return config.getWebpackConfig()
}

So now to add multiple inputs/outputs I only have to add a couple of lines:

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

const baseEncoreConfiguration = generateBaseConfig('app');
baseEncoreConfiguration.name = 'baseEncoreConfiguration'
Encore.reset();

const animationsConfiguration = generateBaseConfig('animations');
animationsConfiguration.name = 'animationsConfiguration'

module.exports = [baseEncoreConfiguration, animationsConfiguration];

If you only add a name when calling the generateBaseConfig function the input will be from {{themeName}}/js/{{name}}.ts and the output will be {{themeName}}/build/{{name}}/assets.html. so in the previous example I had the following files :

user/themes/lingonberry-custom
└── js
Β Β  β”œβ”€β”€ typewriter.ts
Β Β  └──  app.ts

Which generated the following output :

user/themes/lingonberry-custom
β”œβ”€β”€ build
 Β Β  β”œβ”€β”€ animations
 Β Β  β”‚Β Β  β”œβ”€β”€ animations.css
 Β Β  β”‚Β Β  β”œβ”€β”€ animations.js
 Β Β  β”‚Β Β  β”œβ”€β”€ animations.js.gz
 Β Β  β”‚Β Β  β”œβ”€β”€ assets.html
 Β Β  β”‚Β Β  β”œβ”€β”€ assets.html.gz
 Β Β  β”‚Β Β  β”œβ”€β”€ entrypoints.json
 Β Β  β”‚Β Β  β”œβ”€β”€ manifest.json
 Β Β  β”‚Β Β  └── manifest.json.gz
 Β Β  β”œβ”€β”€ app
 Β Β  β”‚Β Β  β”œβ”€β”€ app.css
 Β Β  β”‚Β Β  β”œβ”€β”€ app.css.gz
 Β Β  β”‚Β Β  β”œβ”€β”€ app.js
 Β Β  β”‚Β Β  β”œβ”€β”€ app.js.gz
 Β Β  β”‚Β Β  β”œβ”€β”€ assets.html
 Β Β  β”‚Β Β  β”œβ”€β”€ assets.html.gz
 Β Β  β”‚Β Β  β”œβ”€β”€ entrypoints.json
 Β Β  β”‚Β Β  β”œβ”€β”€ fonts
 Β Β  β”‚Β Β  β”œβ”€β”€ images
 Β Β  β”‚Β Β  β”œβ”€β”€ manifest.json
 Β Β  β”‚Β Β  └── manifest.json.gz
 Β Β  β”œβ”€β”€ entrypoints.json
 Β Β  β”œβ”€β”€ manifest.json
 Β Β  └── manifest.json.gz

Each file input has a separate output directory. Without this the Encore cleanupOutputBeforeBuild function would always empty the main build directory. As the builds run in parallel there would always only be one left in the end, the others deleted.

Adding the generated files to the twig templates

Now that they are generated the output files need to be included in our twig templates. For the main assets I simply added {% include 'app/assets.html' %} in the header tag of my templates base twig file.

I also wanted to make it simple to add files from the backend specific to a certain blog page. To get this done I generated a new grav plugin Header Include.

To add some more options to a page I added a blueprint plugins/header-include/blueprints/header-include.yaml :

form:
  fields:
    tabs:
      fields:
        options:
          type: tab

          fields:
            header-include:
              type: section
              title: HEADER_INCLUDE.PLUGIN.NAME
              underline: true

              fields:
                header.header-include.active:
                  type: toggle
                  toggleable: true
                  label: HEADER_INCLUDE.PLUGIN.ACTIVE
                  help: HEADER_INCLUDE.PLUGIN.ACTIVE_HELP
                  highlight: 1
                  # config-default@: plugins.header-include.active
                  options:
                    1: PLUGIN_ADMIN.YES
                    0: PLUGIN_ADMIN.NO
                  validate:
                    type: bool

                header.include:
                 type: multilevel
                 label: HEADER_INCLUDE.PLUGIN.ITEMS
                 value_only: true
                 validate:
                   type: array

It just contains an on off button, and a list of items we want to include in our header. To include the new animations files in tha page options I set the following configuration : Screenshot%20from%202021-07-03%2017-39-23

Now to make sure all the files defined in the options are actually included I added a template {{themeName}}/partials/includes.html.twig :

{% for filename in page.header.include %}
    {% include filename %}
{% endof %}

and I included that file in my template's base file.

Now when I add some CSS to the animation.scss file (that I stole from CSS-Tricks), a bit of typescript, and I can get these nice effects but only in the pages that need them.

@use 'variables' as *;
.typewriter {
  max-width: fit-content;
  display: block;
  text-align: left;
  overflow: hidden; /* Ensures the content is not revealed until the animation */
  border-right: .15em solid $primary-color;/* The typwriter cursor */
  white-space: nowrap; /* Keeps the content on a single line */
  margin: 0 auto; /* Gives that scrolling effect as the typing happens */
  letter-spacing: .15em; /* Adjust as needed */

  animation:
          typing 3.5s steps(38, end),
          blink-caret .75s step-end infinite;
  &.slow {
    animation:
            typing 7s steps(52, end),
            blink-caret 1.5s step-end infinite;
  }
}

/* The typing effect */
@keyframes typing {
  from { width: 0 }
  to { width: 100% }
}

/* The typewriter cursor effect */
@keyframes blink-caret {
  from, to { border-color: transparent }
  50% { border-color: $primary-color}
}

.retype-btn {
  border-radius: 5px;
  padding: 0.6rem 1.2rem;
  cursor: pointer;
  background-color: $darkmode-bg-color;
  color: $primary-color;
  transition: color 200ms ease-in-out,
  background-color 200ms ease-out;
;
  &:hover {
    color: $darkmode-bg-color;
    background-color: $primary-color;
  }
}
//components/typewriter.ts
export default class Typewriter {

    retypeButtons: HTMLButtonElement[]
    typewriters: HTMLElement[]

    constructor() {
        this.retypeButtons = <HTMLButtonElement[]><any>document.getElementsByClassName('retype-btn');
        this.typewriters = <HTMLElement[]><any>document.getElementsByClassName('typewriter');
        this.retypeButtons.forEach((item) => {
            item.addEventListener('click', (event) => {
                this.typewriters.forEach((item) => {
                    const clone = item.cloneNode(true)
                    item.parentNode?.replaceChild(clone, item)
                })
            })
        })
    }
}
//typewriter.ts
import '../css/animations.scss'

import Typewriter from './components/typewriter'

document.addEventListener("DOMContentLoaded", function () {
    new Typewriter();
});

Here in the Grav admin markdown editor for my blog page I add

<span class="typewriter">I'm a Typewriter !!</span>
<span class="typewriter slow">I'm a sloooow Typewriter !!</span>
<button id="retype-btn" class="retype-btn">Retype</button>

I'm a Typewriter !! I'm a sloooow Typewriter !!

The CSS and JavaScript are now applied but only to this page :) πŸ“ πŸ˜ƒ