Plugins API

Below are the various plugin types you can use to extend and further customize Greenwood.

Overview

Each plugin must return a function that has the following three properties:

Here is an example of creating a plugin in a greenwood.config.js:

export default {
  // ...

  plugins: [
    (options) => {
      return {
        name: "my-plugin",
        type: "resource",
        provider: (compilation) => {
          // do stuff here
        },
      };
    },
  ],
};

The provider function takes a Greenwood compilation object consisting of the following properties:

Adapter

Adapter plugins are designed with the intent to be able to post-process the Greenwood standard build output. For example, moving build output files around into the desired location for a specific hosting provider, like Vercel or AWS.

Usage

An adapter plugin is simply an async function that gets invoked by the Greenwood CLI after all assets, API routes, and SSR pages have been built and optimized. With access to the compilation, you can also process all these files to meet any additional format / output targets.

const greenwoodPluginMyPlatformAdapter = (options = {}) => {
  return {
    type: "adapter",
    name: "plugin-adapter-my-platform",
    provider: (compilation) => {
      return async () => {
        // run your code here....
      };
    },
  };
};

export { greenwoodPluginMyPlatformAdapter };

Example

The most common use case is to "shim" in a hosting platform handler function in front of Greenwood's, which is based on two parameters of Request / Response. In addition, producing any hosting provided specific metadata is also doable at this stage.

Here is an example of the "generic adapter" created for Greenwood's own internal test suite.

import fs from "fs/promises";
import { checkResourceExists } from "../../../../cli/src/lib/resource-utils.js";

function generateOutputFormat(id, type) {
  const path = type === "page" ? `/${id}.route` : `/api/${id}`;
  const ref = id.replace(/-/g, "").replace(/\//g, "");

  return `
    import { handler as ${ref} } from '../public${path}.js';

    export async function handler (request) {
      const { url, headers } = request;
      const req = new Request(new URL(url, \`http://\${headers.host}\`), {
        headers: new Headers(headers)
      });

      return await ${ref}(req);
    }
  `;
}

async function genericAdapter(compilation) {
  const adapterOutputUrl = new URL("./adapter-output/", compilation.context.projectDirectory);
  const ssrPages = compilation.graph.filter((page) => page.isSSR);
  const apiRoutes = compilation.manifest.apis;

  if (!(await checkResourceExists(adapterOutputUrl))) {
    await fs.mkdir(adapterOutputUrl);
  }

  for (const page of ssrPages) {
    const { id } = page;
    const outputFormat = generateOutputFormat(id, "page");

    await fs.writeFile(new URL(`./${id}.js`, adapterOutputUrl), outputFormat);
  }

  for (const [key] of apiRoutes) {
    const { id } = apiRoutes.get(key);
    const outputFormat = generateOutputFormat(id, "api");

    await fs.writeFile(new URL(`./api-${id}.js`, adapterOutputUrl), outputFormat);
  }
}

const greenwoodPluginAdapterGeneric = (options = {}) => [
  {
    type: "adapter",
    name: "plugin-adapter-generic",
    provider: (compilation) => {
      return async () => {
        await genericAdapter(compilation, options);
      };
    },
  },
];

export { greenwoodPluginAdapterGeneric };

Note: Check our Vercel adapter plugin for a more complete example.

Context

Context plugins allow users to extend where Greenwood can look for certain files and folders, like layouts and pages. This allows plugin authors to publish a full set of assets like HTML, CSS and images (a "theme pack") so that Greenwood users can simply "wrap up" their content in a nice custom layout and theme just by installing a package from npm! 💯

Similar in spirit to CSS Zen Garden

🔎 For more information on developing and publishing a Theme Pack, check out our guide on theme packs.

API

At present, Greenwood allows for configuring the following locations as array of (absolute) paths

We plan to expand the scope of this as use cases are identified.

Layouts

By providing paths to directories of layouts, plugin authors can share complete pages, themes, and UI complete with JavaScript and CSS to Greenwood users, and all a user has to do (besides installing the plugin), is specify a layout filename in their frontmatter.

---
layout: acme-theme-blog-layout
---

## Welcome to my blog!

Your plugin might look like this:

/*
 * For context, when your plugin is installed via npm or Yarn, import.meta.url will be /path/to/node_modules/<your-package-name>/
 *
 * You can then choose how to organize and publish your files.  In this case, we have published the layout under a _dist/_ folder, which was specified in the package.json `files` field.
 *
 * node_modules/
 *   acme-theme-pack/
 *     dist/
 *       layouts/
 *         acme-theme-blog-layout.html
 *     acme-theme-pack.js
 *     package.json
 */
export function myContextPlugin() {
  return {
    type: "context",
    name: "acme-theme-pack:context",
    provider: () => {
      return {
        layouts: [
          // when the plugin is installed import.meta.url will be /path/to/node_modules/<your-package>/
          new URL("./dist/layouts/", import.meta.url),
        ],
      };
    },
  };
}

Additionally, you can provide the default app.html and page.html layouts this way as well!

Copy

The copy plugin allows users to copy files around as part of the Greenwood build command. For example, Greenwood uses this feature to copy all files in the user's /assets/ directory to final output directory automatically. You can use this plugin to copy single files, or entire directories.

API

This plugin supports providing an array of "paired" URL objects that can either be files or directories, by providing a from and to location as instances of URLs:

export function myCopyPlugin() {
  return {
    type: "copy",
    name: "plugin-copy-some-files",
    provider: (compilation) => {
      const { context } = compilation;

      return [
        {
          // copy a file
          from: new URL("./robots.txt", context.userWorkspace),
          to: new URL("./robots.txt", context.outputDir),
        },
        {
          // copy a directory (notice the trailing /)
          from: new URL("./pdfs/", context.userWorkspace),
          to: new URL("./pdfs/", context.outputDir),
        },
      ];
    },
  };
}

You can see more examples in the Greenwood repo.

Renderer

Renderer plugins allow users to customize how Greenwood server renders (and prerenders) your project. By default, Greenwood supports using WCC or (template) strings to return static HTML for the content and template of your server side routes. With this plugin for example, you can use Lit's SSR to render your Lit Web Components on the server side instead. (but don't do that one specifically, we already have a plugin for Lit 😊)

API

This plugin expects to be given a path to a module that exports a function to execute the SSR content of a page by being given its HTML and related scripts. For local development Greenwood will run this in a Worker thread for live reloading, and use it standalone for production bundling and serving.

const greenwoodPluginMyCustomRenderer = (options = {}) => {
  return {
    type: "renderer",
    name: "plugin-renderer-custom",
    provider: () => {
      return {
        executeModuleUrl: new URL("./execute-route-module.js", import.meta.url),
        prerender: options.prerender,
      };
    },
  };
};

export { greenwoodPluginMyCustomRenderer };

Options

This plugin type supports the following options:

Examples

Default

The recommended Greenwood API for executing server rendered code is in a function that is expected to implement any combination of these APIs; default export, getBody, getLayout, and getFrontmatter.

You can follow the WCC default implementation for Greenwood as a reference.

Custom Implementation

This option is useful for exerting full control over the rendering lifecycle, like running a headless browser. You can follow Greenwood's implementation for Puppeteer as a reference.

Resource

Resource plugins allow for the manipulation and transformation of files served and bundled by Greenwood. Whether you need to support a file with a custom extension or transform the contents of a file from one type to the other, resource plugins provide the lifecycle hooks into Greenwood to enable these customizations. Examples from Greenwood's own plugin system include:

It uses standard Web APIs for facilitating these transformations such as URL, Request, and Response.

API

A resource "interface" has been provided by Greenwood that you can use to start building your own resource plugins with.

import { ResourceInterface } from "@greenwood/cli/src/lib/resource-interface.js";

class ExampleResource extends ResourceInterface {
  constructor(compilation, options = {}) {
    super();

    this.compilation = compilation; // Greenwood's compilation object
    this.options = options; // any optional configuration provided by the user of your plugin
    this.extensions = ["foo", "bar"]; // add custom extensions for file watching + live reload here, ex. ts for TypeScript
    this.servePage = `static|dynamic`; // optionally opt-in to Greenwood using the plugin's serve lifecycle for processing static pages ('static') or SSR pages and API routes ('dynamic')
  }

  // lifecycles go here
}

export function myResourcePlugin(options = {}) {
  return {
    type: "resource",
    name: "my-resource-plugin",
    provider: (compilation) => new ExampleResource(compilation, options),
  };
}

Note: Using servePage with the 'dynamic' setting requires enabling custom imports.

Lifecycles

A resource plugin in Greenwood has access to four lifecycles, in this order:

  1. resolve - Where the resource is located, e.g. on disk
  2. serve - What are the contents of a resource
  3. preIntercept - transforming the response of a served resource before Greenwood can intercept it
  4. intercept - transforming the response of a served resource
  5. optimize - transforming the response of resource after intercept lifecycle has run (only runs at build time)

Each lifecycle also supports a corresponding predicate function, e.g. shouldResolve that should return a boolean of true|false if this plugin's lifecycle should be invoked for the given resource.

Resolve

When requesting a resource like a file, such as /main.js, Greenwood needs to know where this resource is located. This is the first lifecycle that is run and takes in a URL and Request as parameters, and should return a Request object. Below is an example from Greenwood's codebase.

import fs from "fs";
import { ResourceInterface } from "@greenwood/cli/src/lib/resource-interface.js";

class UserWorkspaceResource extends ResourceInterface {
  async shouldResolve(url, request) {
    const { pathname } = url;
    const { userWorkspace } = this.compilation.context;
    const hasExtension = !["", "/"].includes(pathname.split(".").pop());

    return (
      hasExtension &&
      !pathname.startsWith("/node_modules") &&
      fs.existsSync(new URL(`.${pathname}`, userWorkspace).pathname)
    );
  }

  async resolve(url, request) {
    const { pathname } = url;
    const { userWorkspace } = this.compilation.context;
    const workspaceUrl = new URL(`.${pathname}`, userWorkspace);

    return new Request(workspaceUrl);
  }
}

For most cases, you will not need to use this lifecycle as by default Greenwood will first check if it can resolve a request to a file either in the current workspace or /node_modules/. If it finds a match, it will transform the request into a file:// protocol with the full local path, otherwise the request will remain as the default of http://.

Serve

When requesting a file and after knowing where to resolve it, such as /path/to/user-workspace/main/scripts/main.js, Greenwood needs to return the contents of that resource so can be served to a browser or bundled appropriately. This is done by passing an instance of URL and Request and returning an instance of Response. For example, Greenwood uses this lifecycle extensively to serve all the standard web content types like HTML, JS, CSS, images, fonts, etc and also providing the appropriate Content-Type header.

If you are supporting non standard file formats, like TypeScript (.ts) or JSX (.jsx), this is where you would want to handle providing the contents of this file transformed into something a browser could understand; like compiling the TypeScript to JavaScript.

Below is an example from Greenwood's codebase for serving JavaScript files.

import fs from "fs";
import { ResourceInterface } from "@greenwood/cli/src/lib/resource-interface.js";

class StandardJavaScriptResource extends ResourceInterface {
  async shouldServe(url, request) {
    return url.protocol === "file:" && url.pathname.split(".").pop() === "js";
  }

  async serve(url, request) {
    const body = await fs.promises.readFile(url, "utf-8");

    return new Response(body, {
      headers: {
        "Content-Type": "text/javascript",
      },
    });
  }
}

If this was a TypeScript file, this would be the lifecycle where you would run tsc.

Pre Intercept

After the serve lifecycle comes the preIntercept lifecycle. This lifecycle is useful for transforming an already served resource before Greenwood runs its own intercept lifecycles, since Greenwood assumes all content to be "web safe" by the intercept lifecycle. It takes as parameters an instance of URL, Request, and Response.

This lifecycle is useful for augmenting standard web formats prior to Greenwood operating on them. A good example of this is wanting to run pre-processors like Babel, ESBuild, or PostCSS to "downlevel" non-standard syntax into standard syntax before other plugins can operate on it.

Below is an example of Greenwood's PostCSS plugin using preIntercept on CSS files.

import { ResourceInterface } from "@greenwood/cli/src/lib/resource-interface.js";
import { normalizePathnameForWindows } from "@greenwood/cli/src/lib/resource-utils.js";
import postcss from "postcss";

async function getConfig() {
  // ...
}

class PostCssResource extends ResourceInterface {
  constructor(compilation, options) {
    super(compilation, options);
    this.extensions = ["css"];
    this.contentType = "text/css";
  }

  async shouldPreIntercept(url) {
    return url.protocol === "file:" && url.pathname.split(".").pop() === this.extensions[0];
  }

  async preIntercept(url, request, response) {
    const config = await getConfig(this.compilation, this.options.extendConfig);
    const plugins = config.plugins || [];
    const body = await response.text();
    const css =
      plugins.length > 0
        ? (await postcss(plugins).process(body, { from: normalizePathnameForWindows(url) })).css
        : body;

    return new Response(css, { headers: { "Content-Type": this.contentType } });
  }
}

Intercept

After the preIntercept lifecycle comes the intercept lifecycle. This lifecycle is useful for transforming already served resources and returning an instance of a Response with the new transformation. It takes in as parameters an instance of URL, Request, and Response.

This lifecycle is useful for augmenting standard web formats, where Greenwood can handle resolving and serving the standard contents, allowing plugins to handle any one-off transformations.

A good example of this is Greenwood's "raw" plugin which can take a standard web format like CSS, and convert it onto a standard ES Module when a ?type=raw is added to any import, which would be useful for CSS-in-JS use cases, for example:

import styles from "./hero.css?type=raw";
import { ResourceInterface } from "@greenwood/cli/src/lib/resource-interface.js";

class ImportRawResource extends ResourceInterface {
  async shouldIntercept(url) {
    const { protocol, searchParams } = url;
    const type = searchParams.get("type");

    return protocol === "file:" && type === "raw";
  }

  async intercept(url, request, response) {
    const body = await response.text();
    const contents = `const raw = \`${body.replace(/\r?\n|\r/g, " ").replace(/\\/g, "\\\\")}\`;\nexport default raw;`;

    return new Response(contents, {
      headers: new Headers({
        "Content-Type": "text/javascript",
      }),
    });
  }
}

Optimize

This lifecycle is only run during a build (greenwood build) and after the intercept lifecycle, and as the name implies is a way to do any final production ready optimizations or transformations. It takes as parameters an instance of URL and Response and should return an instance of Response.

Below is an example from Greenwood's codebase for minifying CSS. (The actual function for minifying has been omitted for brevity)

import { ResourceInterface } from "@greenwood/cli/src/lib/resource-interface.js";

function bundleCss() {
  // ..
}

class StandardCssResource extends ResourceInterface {
  async shouldOptimize(url, response) {
    const { protocol, pathname } = url;

    return (
      this.compilation.config.optimization !== "none" &&
      protocol === "file:" &&
      pathname.split(".").pop() === "css" &&
      response.headers.get("Content-Type").indexOf("text/css") >= 0
    );
  }

  async optimize(url, response) {
    const body = await response.text();
    const optimizedBody = bundleCss(body);

    return new Response(optimizedBody);
  }
}

You can see more in-depth examples of resource plugin by reviewing the default plugins maintained in Greenwood's CLI package.

Rollup

Though rare, there may be cases for tapping into the bundling process for Greenwood. If so, this plugin type allow users to tap into Greenwood's Rollup configuration to provide any custom Rollup behaviors you may need.

Simply use the provider method to return an array of Rollup plugins:

import bannerRollup from "rollup-plugin-banner";
import fs from "fs";

const packageJson = JSON.parse(fs.readFileSync("./package.json", "utf-8"));

export function myRollupPlugin() {
  const now = new Date().now();

  return {
    type: "rollup",
    name: "plugin-something-something",
    provider: () => [
      bannerRollup(`/* ${packageJson.name} v${packageJson.version} - built at ${now}. */`),
    ],
  };
}

Server

Server plugins allow developers to start and stop custom servers as part of the development lifecycle of Greenwood.

These lifecycles provide the ability to do things like:

API

Although JavaScript is loosely typed, a server "interface" has been provided by Greenwood that you can use to start building your own server plugins. Effectively you just have to provide two methods:

They can be used in a greenwood.config.js just like any other plugin type.

import { myServerPlugin } from "./my-server-plugin.js";

export default {
  // ...

  plugins: [myServerPlugin()],
};

Example

The below is an excerpt of Greenwood's internal LiveReload server plugin.

import { ServerInterface } from "@greenwood/cli/src/lib/server-interface.js";
import livereload from "livereload";

class LiveReloadServer extends ServerInterface {
  constructor(compilation, options = {}) {
    super(compilation, options);

    this.liveReloadServer = livereload.createServer({
      /* options */
    });
  }

  async start() {
    const { userWorkspace } = this.compilation.context;

    return this.liveReloadServer.watch(userWorkspace, () => {
      console.info(`Now watching directory "${userWorkspace}" for changes.`);
      return Promise.resolve(true);
    });
  }
}

export function myServerPlugin(options = {}) {
  return {
    type: "server",
    name: "plugin-livereload",
    provider: (compilation) => new LiveReloadServer(compilation, options),
  };
}

Source

The source plugin allows users to include external content as pages that will be statically generated just like if they were a markdown or HTML in your pages/ directory. This would be the primary API to include content from a headless CMS, database, the filesystem, SaaS provider (Notion, AirTables) or wherever else you keep it.

API

This plugin supports providing an array of "page" objects that will be added as nodes in the graph.

// my-source-plugin.js
export const customExternalSourcesPlugin = () => {
  return {
    type: "source",
    name: "source-plugin-myapi",
    provider: () => {
      return async function () {
        // this could just as easily come from an API, DB, Headless CMS, etc
        const artists = await fetch("http://www.myapi.com/...").then((resp) => resp.json());

        return artists.map((artist) => {
          const { bio, id, imageUrl, name } = artist;
          const route = `/artists/${name.toLowerCase().replace(/ /g, "-")}/`;

          return {
            title: name,
            body: `
              <h1>${name}</h1>
              <p>${bio}</p>
              <img src='${imageUrl}'/>
            `,
            route,
            id,
            label: name,
          };
        });
      };
    },
  };
};

In the above example, you would have the following statically generated in the output directory:

public/
  artists/
    <name1>/index.html
    <name2>/index.html
    <nameN>/index.html

And accessible at the following routes in the browser: