Getting Started with Scully Plugin Development

Scully's extensibility is one of its strongest features, and in this guide we're going to expand its capabilities by creating a custom RSS plugin. The plugin we build will generate an RSS feed for a blog project built with Angular and Scully.

Our RSS plugin will be implemented as a routeDiscoveryDone plugin type. This particular type fires once Scully has completed the route discovery phase. The data harvested during that phase becomes the raw material for constructing the RSS output. Route discovery itself is orchestrated by a router plugin; more details can be found here.

There are nine distinct plugin types available in Scully, each hooking into a different stage of the build pipeline. For a full rundown on the available types and their purposes, check out the official documentation here.

Environment Preparation

  • Make sure your Angular project is initialized with Scully — follow this guide: Link.

Implementing the Plugin

Setup and Initial Configuration

Assuming you have used the schematics to configure Scully in your Angular project, a Scully directory should be present at the workspace root. This directory includes a tsconfig file specifically for Scully plugins, along with a plugins subdirectory where our custom plugin will be placed.

We'll create a fresh file named rss.ts inside the scully/plugins directory. This file will hold all the logic for our RSS generation plugin.

Writing the Plugin Logic

To craft the RSS feed, we'll leverage the Feed npm package, a TypeScript-friendly library that simplifies creating syndicated feeds.

The plugin will be triggered after Scully finishes discovering all the application's routes. It will receive an array of routes, each accompanied by its associated route data.

const createRSSFeed = async (routes: HandledRoute[]) => {
  // code here
}
Enter fullscreen mode Exit fullscreen mode

Our first step involves creating a new Feed instance to represent the channel or feed itself.

Before we can use it, we must import the Feed class into our plugin file.

import { Feed } from 'feed';
Enter fullscreen mode Exit fullscreen mode

With the import in place, we can now instantiate the Feed object, providing the necessary configuration for our site's feed.

const feed = new Feed({
  title: 'John Doe Blog',
  language: 'en-us',
  author: {
    email: 'johndoe@example.com',
    name: 'John Doe',
  },
  description: 'about you website or blog',
  id: 'https://example.com',
  link: 'https://example.com/blog',
  favicon: 'https://example.com/favicon.png',
  copyright: "John Doe Copyright"
});
Enter fullscreen mode Exit fullscreen mode

Make sure the details provided here match your project's specific information.

Now, we'll iterate through the routes that Scully has discovered. For each route found, an item will be added to our RSS feed.

routes.forEach((route) => {
    // add each item to an RSS Feed Article  
})
Enter fullscreen mode Exit fullscreen mode

Within the loop, we'll construct an item for the RSS feed. The properties of this item, such as title, date, content, and others, will be populated using the data available at route.data.* for that specific route.

routes.forEach((route) => {
  feed.addItem({
    title: route.data.title,
    date: new Date(route.data.publishedAt),
    link: route.data.link,
    // loop through the names of the authors if list
    author: [
      {
        email: route.data.author.email,
        name: route.data.author.email,
      },
    ],
    // uses tags as categories
    category: route.data?.tags?.map((t: Tag) => ({
      name: t.name,
    })),
    content: route.data.html,
    id: route.data.id,
    image: route.data.featured_image,
    published: new Date(route.data.publishedAt),
  });
})
Enter fullscreen mode Exit fullscreen mode

Note: You'll need to adjust the item property assignments to align with the structure of your own data. For content written in markdown, these property names correspond to the fields in the Front Matter.

To finish, we need to write the generated RSS content to an XML file located within Scully's output folder. The package fs-extra is a good fit for this file operation, so let's begin by installing it.

Using Yarn:

yarn add --dev fs-extra
Enter fullscreen mode Exit fullscreen mode

Using NPM:

npm i -D fs-extra
Enter fullscreen mode Exit fullscreen mode

Once installed, we'll import the outputFileSync function from the fs-extra package to handle writing the file.

import { outputFileSync } from 'fs-extra';
Enter fullscreen mode Exit fullscreen mode

Now we can persist the rss feed content to a file.

// the output directory of your scully builds artefacts
const outDir = './dist/static';

outputFileSync(join(outDir, 'blog', `feed.xml`), feed.rss2());
Enter fullscreen mode Exit fullscreen mode

As an added benefit, the Feed library also lets us generate JSON and Atom feed formats with similar ease:

outputFileSync(join(outDir, 'blog', `feed.atom`), feed.atom1());
outputFileSync(join(outDir, 'blog', `feed.json`), feed.json1());
Enter fullscreen mode Exit fullscreen mode

With that, the core logic is complete. Here is the final structure of the plugin function.

const createRSSFeed = async (routes: HandledRoute[]) => {
  log(`Generating RSS Feed for Blog`);

   const feed = new Feed({
    title: 'John Doe Blog',
    language: 'en-us',
    author: {
      email: 'johndoe@example.com',
      name: 'John Doe',
    },
    description: 'about you website or blog',
    id: 'https://example.com',
    link: 'https://example.com/blog',
    favicon: 'https://example.com/favicon.png',
    copyright: "John Doe Copyright"
  });

  routes.forEach((route) => {
    feed.addItem({
      title: route.data.title,
      date: new Date(route.data.publishedAt),
      link: route.data.link,
      // loop through the names of the authors if list
      author: [
        {
          email: route.data.author.email,
          name: route.data.author.email,
        },
      ],
      // uses tags as categories
      category: route.data?.tags?.map((t: Tag) => ({
        name: t.name,
      })),
      content: route.data.html,
      id: route.data.id,
      image: route.data.featured_image,
      published: new Date(route.data.publishedAt),
    });
  })

  try {
    const outDir = './dist/static';
    outputFileSync(join(outDir, 'blog', `feed.xml`), feed.rss2());
    log(`✅ Created ${yellow(`${outDir}/blog/feed.xml`)}`);
    outputFileSync(join(outDir, 'blog', `feed.atom`), feed.atom1());
    log(`✅ Created ${yellow(`${outDir}/blog/feed.atom`)}`);
    outputFileSync(join(outDir, 'blog', `feed.json`), feed.json1());
    log(`✅ Created ${yellow(`${outDir}/blog/feed.json`)}`);
  } catch (error) {
    logError('❌ Failed to create RSS feed. Error:', error);
    throw error;
  }
};
Enter fullscreen mode Exit fullscreen mode

Note: The log and logError functions are utilities provided by Scully itself and can be imported from the core package @scullyio/scully.

Registering with Scully

To make Scully aware of our new plugin, we'll give it a unique identifier. We start by declaring and exporting a constant that will hold this plugin name.

export const BlogRSSFeed = Symbol('BlogRSSFeed');
Enter fullscreen mode Exit fullscreen mode

This exported constant can be imported into your scully.config file in order to reference the plugin easily.

We'll now register our plugin function with Scully under the routeDiscoveryDone extension point. This tells Scully to execute our code after it finishes its route discovery phase.

registerPlugin('routeDiscoveryDone', BlogRSSFeed, createRSSFeed);
Enter fullscreen mode Exit fullscreen mode

Integrating into the Build

The final step is to activate our plugin by adding it to the postRenders array in the Scully configuration. This can be configured in two distinct ways. The first approach applies the plugin globally to all routes in the application:

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'project-name',
  outDir: './dist/website',
  defaultPostRenderers: [BlogRSSFeed], // for all routes
  routes: {
    '/blog/:slug': {
        // ...
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

The second method is more targeted and allows you to attach the plugin to a specific route, such as blog. This is particularly advantageous when you wish to generate an RSS feed exclusively for a particular part of your website, like the blog section, rather than the entire site.

export const config: ScullyConfig = {
  // ...
  routes: {
    '/blog/:slug': {
      postRenderers: [BlogRSSFeed],
      // ...
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Throughout this guide, we have walked through the process of developing a custom Scully plugin that produces RSS feeds for an Angular application. We implemented a routeDiscoveryDone plugin, which executes once the app's routes are identified, leveraging that route information to generate an RSS feed for each route.

Further Reading

  • Improving Scully Build Times with GitHub Actions - Link.
  • Angular CDK's Platform Module Explained - Link.
  • Official Scully Documentation - Link.