DEV Community

Preston Lamb
Preston Lamb

Posted on • Originally published at Medium on

Creating Custom Scully Plugins

tldr;

Scully.io is a great tool for Angular developers. Its tagline is, "Scully makes building, testing and deploying JAMstack apps extremely simple." Essentially, you build your application in Angular and then Scully will take it and generate static HTML and CSS for you. Those assets can be deployed, and the end result is a high quality, blazing fast experience. In this post, we'll talk about how you can extend the default functionality of Scully to better suit your needs. This article will illustrate how to create a plugin, and adding that extra functionality is simpler than you may think.

Plugin Types

There are several types of plugins you can create for your Scully app, depending on when in the build pipeline you need to jump in. Here are the different types of plugins you can create and a brief explanation of each, pulled directly from Scully's docs:

  • router plugins teach Scully how to get the required data to be pre-render pages from the route-params.
  • render plugins are used to transform the rendered HTML. After the Angular application renders, the HTML content is passed to a render plugin where it can be further modified.
  • routeProcess plugins are plugins that can modify the handled route array, before rendering the routes starts
  • fileHandler plugins are used by the contentFolder plugin during the render process. The contentFolder plugin processes the folders for markdown files or other file type the folders may contain. The render process processes any existing fileHandler plugin for any file extension type.
  • routeDiscoveryDone plugins are called automatically after all routes have been collected and all router plugins have finished.
  • allDone plugins are like routeDiscoveryDone plugins, except they are called after Scully finishes executing all its processes.
  • Scully has a category of system plugins. Unlike the other plugin categories those plugins don't have a set interface, and do use a symbol for their name.

With these seven types of plugins, you can create a lot of extra functionality in your Scully app. For example, I recently wanted the title from my blog posts (written in Markdown) to be added as the HTML document's title. There are several ways to do this, but one way is to write a custom render plugin. This plugin gives you access to the rendered HTML of a page, as well as some data about the route, and allows you to alter it in some way. In my case, I looked for the title attribute on the route's data and added that to the rendered HTML.

Another type of plugin that's useful is the routeDiscoveryDone plugin. This type of plugin is called after Scully finds all the routes in the app and any router plugins are done running. A use case for this plugin is creating an RSS feed from the routes in your Scully application. You can see an example here.

Now that we've covered the types of plugins that you can create and a couple examples of use cases, let's look at how you can create a custom plugin.

Plugin Return Values

All Scully plugins return a Promise<unknown>, as illustrated in the interface:

interface plugin {
  (...parameters:any) => Promise<unknown>
}
Enter fullscreen mode Exit fullscreen mode

For example, a render plugin returns a Promise. A router plugin returns an array of HandledRoutes wrapped in a Promise. Some plugins don't actually return anything, but the return value is still wrapped in a Promise, so its return value is Promise<void>.

It's important to know that all the plugins return a Promise so that if you need to invoke the function you remember to await the result, or chain a .then to the function call.

Creating a Custom Scully Plugin

When you initialize your Angular application with the Scully schematic, a folder is created called scully. Inside that folder is another folder, plugins. The skeleton code for a plugin is created for you, or you can create your own plugin file. There are two main parts to the plugin: the plugin function and the registration of the plugin. Here's an example of the plugin function for a render plugin:

// ./scully/plugins/custom-plugin.ts

export const customPlugin = Symbol('customPlugin');

const customPluginFunction = async (html: string, route: HandledRoute): Promise<string> => {
  // do something
  return Promise.resolve(html);
};
Enter fullscreen mode Exit fullscreen mode

Quick note: you can identify a plugin by using a string or a Symbol. Either way works fine, but the upside to using a Symbol is that there will be no name collisions. If you need more information on Symbols, you can read this blog post. In short, Symbol values are guaranteed to be unique, even if you pass the same string into the Symbol constructor for two separate variables. So, for plugins, this means no name collisions.

This function has two parameters, the rendered html, and the route. The latter contains any route data from Scully. At the end of the plugin, the HTML should be returned. Before returning it, you can alter it in any way you need to. Here's an example of a routeDiscoveryDone plugin:

// ./scully/plugins/custom-plugin.ts

export const customPlugin = Symbol('customPlugin');

function customPluginFunction(routes: HandledRoute[]) {
  const blogPosts = routes.filter((r: HandledRoute) =>
    r.route.includes('/blog')
  );
  // Do something with the blog posts
}
Enter fullscreen mode Exit fullscreen mode

This type of plugin receives an array of routes, allowing you to do what you need with them. As a side note, this is the type of above-mentioned RSS plugin by the team at Notiz.dev.

After the function is created, you need to register the plugin. You can do that by importing the registerPlugin method from @scullyio/scully. The method takes a plugin type, plugin name, and plugin function as parameters. Here's an example of registering a plugin:

// ./scully/plugins/custom-plugin.ts

const { registerPlugin } = require('@scullyio/scully');

registerPlugin('render', customPlugin, customPluginFunction)
Enter fullscreen mode Exit fullscreen mode

Now that the plugin is registered, you're ready to use it. For render plugins, you need to add the name of the plugin to the defaultPostRenderers array in the top level of the site's Scully config or the postRenderers array for a specific set of routes in the Scully config:

// scully.your-site.config.ts

import { customPlugin } from './scully/plugins/custom-plugin';

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'personal-site',
  outDir: './dist/static',
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: {
        folder: './blog',
      },
      postRenderers: [customPlugin],
    },
  },
  defaultPostRenderers: [customPlugin],
}
Enter fullscreen mode Exit fullscreen mode

For the routeDiscoveryDone plugins, they just need to be registered with Scully from the scully.my-site.config.ts file to be run. They don't need to be added to the postRenderers or defaultPostRenderers array like the render plugin.

Example Plugins

The Scully GitHub repository has some example plugins that can be used as a template for you to build your own. In addition, you should be able to browse the repositories of community plugins to see how the plugins are created. Here is a list of some community plugins that are good examples:

findPlugin Method

The Scully team doesn't recommend you export the plugin function from the file where the plugin is defined. This ensures that the plugin function doesn't bleed out into other parts of the system. There are times, though, when you need to invoke the plugin function manually. If you need to do this, you can get access to the plugin with the findPlugin method provided by Scully. The method takes one to three parameters. They are:

  • The plugin type, name, or symbol
  • The plugin name or symbol
  • a throwOnNotFound boolean

If you pass the plugin type (i.e. RenderPlugin or RouterPlugin) as the first parameter, the second parameter needs to be passed as well and should be the name or Symbol of the plugin. If the first parameter is the name or Symbol, you don't need any other parameters.

The throwOnNotFound boolean is defaulted to true, and will generally not be needed by external programs. However, if you're using optional plugins then you can change this value to false. If the plugin is not found, the function will return undefined.

// ./scully/plugins/custom-plugin.spec.ts
const pluginName = Symbol('customPlugin');
const customPlugin = findPlugin(pluginName);
Enter fullscreen mode Exit fullscreen mode

Now that you have access to the plugin, you can invoke it by passing it the needed parameters. For example, a render plugin generally needs an html string and a HandledRoute. A router plugin generally takes a route string and a config parameter.

// ./scully/plugins/custom-plugin.spec.ts

const pluginResult = await customPlugin(htmlString, handledRouteData);
Enter fullscreen mode Exit fullscreen mode

Another reason you might need the findPlugin method is to create a new plugin by composing other plugins. For example, let's say you have two render plugins, pageTitle and canonicalLink. Next, you want to create a new plugin called seo. This plugin will run the first two plugins, and then do some more work. That new plugin may look like this:

registerPlugin('render', 'seo', async (html, route) => {
  const pageTitle = findPlugin('pageTitle');
  const canonicalLink = findPlugin('canonicalLink');

  html = await pageTitle.then(canonicalLink);

  // Do some more SEO work

  return Promise.resolve(html);
});
Enter fullscreen mode Exit fullscreen mode

You now have a new render plugin called seo that does all of your SEO work for you, with the help of a couple other plugins.

Conclusion

Scully's plugin system is incredibly powerful and gives you the ability to add any extra features that you might need. There is a growing catalogue of community plugins, all of which are relatively simple to add to your site and add great value. Most, if not all, are on NPM and start with scully-plugin. If you can't find what you need, create your own!

Top comments (0)