Custom plugins for Scully - Angular Static Site Generator

Sam Vloeberghs - Jan 26 '20 - - Dev Community

Originally published at https://samvloeberghs.be on January 20, 2020

Target audience

Scully, the Static Site Generator for Angular, comes with a plugin system that allows us to control which pages to prerender of our Angular application and how to prerender them. In this article we will learn how to setup custom plugins for your specific use cases. If you care about the performance of your Angular application or website, please continue reading.

What is Scully?

Scully is a static site generator, or SSG, for Angular. It takes your full-blown Angular application and transforms the stable state of all the publicly available pages into their static HTML snapshot. You can deploy these HTML pages to a static webserver, with or without the original Angular application, and your application will load instantly. Even without JS enabled, because all the content is available in the original HTML returned from the server.

This has some important consequences and benefits. The most important one is that the content of your page is available instantly. After your HTML page has loaded, the application can still bootstrap as the Angular app, giving you the full SPA functionality. But meanwhile the user will not be blocked to read or use the page until the Angular app has started up.

Getting started with Scully

Getting started with Scully is easy. The documentation on the Github project will get you going with setting up your first project. At the bottom of the article you can find more links and resources about setting up Scully.

Support for plugins

Scully can generate your pages because of a discovery process that finds the available routes of your application by scanning all the routing modules. Some routes are easy to discover, like for example the /about route. But other routes, for example dynamic routes like /blog/:slug, need to be listed based on known data, coming from for example a DB or API interface.

The lists of available pages for these dynamic routes are built using plugins. There are two types of plugins available, router plugins and renderer plugins. As an example, Scully has a built-in router plugin of type RouteTypes.json that will load a JSON list and use the items in the list as a source for the available pages.

// scully.config.js
const {
  RouteTypes,
} = require('@scullyio/scully');

exports.config = {
  projectRoot: './src/app',
  routes: {
    '/user/:userId': {
      type: RouteTypes.json,
      userId: {
        url: 'https://jsonplaceholder.typicode.com/users',
        property: 'id',
      }
    }
  }
}

If we consider that our JSON source https://jsonplaceholder.typicode.com/users has around 10 users, this plugin will add 10 routes to the list of pages to prerender using our Angular application and Scully. This is done by defining a configuration (line 10-13) that will instruct Scully which property to use, from the objects in the list of the JSON data source, for replacing the dynamic :userId in the route definition (line 8).

And finally the complete list of pages to prerender is generated! Scully keeps track of these list in the /src/assets/scully-routes.json file.

// /src/assets/scully-routes.json
[
  {"route": "/"}, {"route": "/about"},
  ...
  {"route": "/users/1"},
  {"route": "/users/2"},
  ...
  {"route": "/user/10"}
]

The next step is building our own plugins, but let's first shine some light on the example project we will use to explain the details and decide which plugins we need to build.

Example project

The example project is a simple Angular application with a few static routes, like /about, and dynamic routes like /news that lists the available newsitems and /news/:id/:slug that shows the detail of a newsitem. The list of newsitems is retrieved via HttpClient from a static resource /assets/news.json and the newsdetail is available at /assets/news/{:id}.json.

example project view

Based on the data of /assets/news.json the list of all routes for our example application looks like the list below. Remember, Scully is generating the complete list.

// /src/assets/scully-routes.json
[
  {"route":"/"},
  {"route":"/about"},
  {"route":"/news"},
  {"route":"/news/archive"},
  {"route":"/news/1/newsitem-1"},
  {"route":"/news/2/newsitem-2"},
  {"route":"/news/5/newsitem-5"},
  {"route":"/news/99/newsitem-99"}
]

Custom router plugin

To be able to generate the list of pages for our news module, that has detail pages in the form of news/:id/:slug with 2 parameters :id and :slug, we need to program a custom router plugin for Scully. A router plugin is easy, you just need to write some code that will generate a list of routes, based on a given configuration. Let's break down the code from our newsPlugin:

// /plugins/newsPlugin.js
const {registerPlugin, routeSplit} = require('@scullyio/scully');
const {httpGetJson} = require('@scullyio/scully/utils/httpGetJson');

const NewsPlugin = 'news';

const newsPlugin = async(route, config) => {
  const list = await httpGetJson(config.url);
  const {createPath} = routeSplit(route);
  const handledRoutes = [];
  for (let item of list) {
    handledRoutes.push({
      route: createPath(item.id, item.slug)
    });
  }
  return handledRoutes;
};

const newsPluginValidator =  async conf => [];

registerPlugin('router', 'news', newsPlugin, newsPluginValidator);
exports.NewsPlugin = NewsPlugin;
  1. The plugin function, defined at line 6, is an async function that expects the route we are handling, in this case the news/:id/:slug route. A config object as the second parameter, that is tight to the route, contains all the information we need to generate the list urls. In this case we only need a url.
  2. At line 8 we generate a createPath function based on the given route. This function will be used to build the final page url, based on the JSON data we retrieved from the url defined in the config object and the original route.
  3. The code that follows in the async function is the actual implementation generating all the urls available for this route.
  4. Finally, we register the plugin to make it available for Scully. The first parameter defines the type of plugin, the second parameter marks the name of the plugin. We also export the NewsPlugin as a type. The last parameter is a validator function, but we will not cover this topic in this blogpost.

Configuring the new plugin

Configuring the new router plugin is easy! In our Scully configuration object, we define our route at line 8-11 and we give it the type of our newsPlugin. The only configuration we needed for our custom plugin was the url to fetch the JSON data from, and we are done!

// scully.config.js
const {RouteTypes} = require('@scullyio/scully');

const {News} = require('./plugins/newsPlugin');

exports.config = {
  projectRoot: './src/app',
  routes: {
    '/news/:id/:slug': {
      type: News,
      url: 'http://localhost:4200/assets/news.json',
    },
  }
};

When running Scully with npm run scully the new router plugin is used and the generated output will be a set of available pages with news details:

// /src/assets/scully-routes.json
[
    ...
    {"route":"/news"},
    {"route":"/news/archive"},
    {"route":"/news/1/newsitem-1"},
    {"route":"/news/2/newsitem-2"},
    {"route":"/news/5/newsitem-5"},
    {"route":"/news/99/newsitem-99"}
]

Custom renderer plugin

Custom renderer plugins can be used to perform some sort of modification on the generated HTML as postRenderers. These postRenderers can be defined as a default, for all rendered pages, or defined on the level of each route. In the latter case, these postRenderers will only be executed on the pages generated for that specific route.

A typical thing to do after generating the static HTML of our pages is to minify the HTML. By removing unnecessary code we can make our pages smaller and faster, which will result in more performant loading times. We can easily minify the HTML by defining a minifyHtml renderer plugin.

// /plugins/minifyHtmlPlugin.js
import { registerPlugin } from '@scullyio/scully';
import { minify, Options } from 'html-minifier';

const defaultMinifyOptions: Options = {
    caseSensitive: true,
    removeComments: true,
    collapseWhitespace: true,
    collapseBooleanAttributes: true,
    removeRedundantAttributes: true,
    useShortDoctype: true,
    removeEmptyAttributes: true,
    minifyCSS: true,
    minifyJS: true,
    removeScriptTypeAttributes: true,
    removeStyleLinkTypeAttributes: true,
    // don't remove attribute quotes, not all social media platforms can parse this over-optimization
    removeAttributeQuotes: false,
    // don't remove optional tags, like the head, not all social media platforms can parse this over-optimization
    removeOptionalTags: false,
};

export const minifyHtmlPlugin = async (html, route) => {
    const minifiedHtml = minify(html, defaultMinifyOptions);
    return Promise.resolve(minifiedHtml);
};

const minifyHtmlPluginValidator = async () => [];
export const MinifyHtml = 'minifyHtml';
registerPlugin('render', MinifyHtml, minifyHtmlPlugin, minifyHtmlPluginValidator);
  1. The first object you see, line 4-20, is the configuration object we use for the HTML minifier. This is the same minifier I used explaining how to implement Better sharing on social media platforms with Angular Universal.
  2. Just as with the custom router plugin for we define an async function. This function takes the HTML and associated route as input. In our case we don't need the route, we just transform the HTML by minifying it and return it as resolved Promise.
  3. Finally, we register the plugin to make it available for Scully. The first parameter defines the type of plugin, the second parameter marks the name of the plugin. We also export the MinifyHtml as a type. We can assign a validator function as well, but this is not in the scope of this blogpost!

Configuring the new plugin

Configuring the new renderer plugin is, again, easy! In our Scully configuration object, we can define a list of postRenderers as a default for all routes using the defaultPostRenderers option. By configuring Scully like this every page that gets statically generated will be minified.

Just keep in mind that if you define a route configuration, the same postRenderers need to be attached to the route configuration using postRenderers. The defaultPostRenderers will not be executed in these cases.

// scully.config.js
const {RouteTypes} = require('@scullyio/scully');

const {MinifyHtml} = require('./plugins/minifyHtmlPlugin');
const {News} = require('./plugins/newsPlugin');

const postRenderers = [MinifyHtml];

exports.config = {
  projectRoot: './src/app',
  defaultPostRenderers: postRenderers,
  routes: {
    '/news/:id/:slug': {
      type: News,
      url: 'http://localhost:4200/assets/news.json',
      postRenderers: postRenderers,
    }
  }
};

Running Scully with npm run scully will now not only generate the correct newsdetail pages, it will also minify the HTML of the generated pages:

minified html view

Using the scully-minify-html plugin

Because it is a plugin and we can share code via npm it would be silly to rewrite the minifyHtml plugin, except for learning purposes ofcourse. Installing it is as easy as running npm i -s scully-minify-html in your Angular project. This will install the plugin for you. The only thing left is to configure it in your scully.config.js file as follows:

// scully.config.js
const {RouteTypes} = require('@scullyio/scully');
const {MinifyHtml} = require('scully-minify-html');

// custom plugins in ./plugins/*.js
const {News} = require('./plugins/newsPlugin');

const postRenderers = [MinifyHtml];

exports.config = {
  projectRoot: './src/app',
  defaultPostRenderers: postRenderers,
  routes: {
    '/news/:id/:slug': {
      type: News,
      url: 'http://localhost:4200/assets/news.json',
      postRenderers: postRenderers,
    }
  }
};

Let's breakdown the code into little steps explaining what is going on:

  1. First of all we import the required type MinifyHtml from our freshly installed Scully plugin.
  2. At line 7 we define the postRenders list and apply it to the defaultPostRenders (line 11), to run for all routes, and/or configure it per route via postRenderers (line 16). Basically everything we did before implementing the custom renderer plugin.

Conclusion

In this article we have learned how to program and configure custom plugins for Scully. With those custom plugins we can control which pages to generate with router plugins and transform the genenerated HTML with renderer plugins.

We can configure Scully per route or we can use global configuration options to get to our solution. Keep in mind that Scully is still in full development so expect things to change.

Further reading

Special thanks to

  • Jeffrey Bosch for reviewing this post and providing valuable and much-appreciated feedback!
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .