Plugin Development

Terrazzo plugins are inspired by Rollup, and the philosophy that token management should be as easy as possible. Though making a Terrazzo plugin doesn’t require any knowledge of Rollup, those familiar with Rollup will find a quick ramp-up.

Getting Started

Terrazzo’s plugin system revolves around 2 key steps: transform, then build. In the transform step, you are generating possible values for a format, e.g. css or js, almost as if you’re populating a database. In the build step, you query those values, and assemble the output format however you’d like. In other words, transform is for generating values, and build is for assembling those values.

token atransformcssbuildoutput.csstoken bcsstoken ccsstoken dcsstoken ecsstoken fcss

“Why the need for two steps? Why not one?” Transformations happen in a shared space, so that means that multiple plugins can see what the transformed values are for usage. For example, consider plugin-css and plugin-sass:

token atransformcssbuildoutput.csstoken bcsstoken ccssplugin-cssplugin-sassbuildoutput.scss

When using the Sass plugin, we want to use CSS variables to get cascading and automatic mode switching. And with a shared transform step, only one plugin has to generate values for a format.

To see what this looks like in code, we’ll look at a simplified version of the CSS plugin:

import { formatCss } from "culori";
import { kebabCase } from "scule";

export default function clampColor(userOptions) {
  return {
    name: "my-css-plugin",
    async transform({ tokens, setTransform }) {
      for (const [id, token] of Object.entries(tokens)) {
        switch (token.$type) {
          case "color": {
            setTransform(id, {
              format: "css",
              localID: `--${kebabCase(id)}`,
              value: formatCss(token.$value), // convert original format into CSS-friendly value
            });
            break;
          }

          // … other $types here
        }
      }
    },
    async build({ getTransforms, outputFile }) {
      const output = [];
      output.push(":root {");
      for (const token of getTransforms({ format: "css", id: "*" })) {
        // renders "--my-token-id: color(srgb 0.6 0 0.3);"
        output.push(`  ${token.localID ?? token.token.id}: ${token.value};`);
      }
      output.push("}", "");
      outputFile("my-file.css", output.join("\n"));
    },
  };
}

In the transform step we’re:

  1. Iterating over the tokens object with Object.entries
  2. Splitting apart the tokens by $type (only color is shown for brevity)
  3. Calling setTransform() which takes the token id, and an object with:
    • format: Format can be any string, but prefer matching file extensions whenever possible (e.g. css, js, json, etc).
    • localID: What this token is referred to within the format (e.g. color.base.blue.500 becomes --color-base-blue-500 in CSS)
    • value: The transformed value for this format.
      • Note: values can be either a string for simple values, or Record<string, string> if a token stores multiple values (needed for typography, border, etc.)
    • mode: (not shown) Configures the value to show up for a certain mode (if omitted, it’s the default or “global” mode)

All of this creates a “database” for the css format that can be queried in the next step. Inside the build step, we query those with getTransforms() which accepts an object as its param with the keys:

  1. format: the same Format produced in the previous step
  2. id: (optional) a glob or array of glob patterns to filter tokens
  3. $type: (optional) Token type(s) to filter by (if omitted, it will return all modes)
  4. mode: (not shown) Query only for specific modes (also accepts globs)

The build step can run as many queries as it wants, requesting the tokens in any order, so that the output file can be built in any order. When the file is complete, calling buildFile(filename, contents) will save the file and Terrazzo will write the file to disk. The build step can output as many files as it wants.

Lastly, you can test your own plugin out locally! Simply add it to terrazzo.config.js:

import { defineConfig } from "@terrazzo/cli";
import myCssPlugin from "./my-css-plugin.js";

export default defineConfig({
  plugins: [myCssPlugin()],
});

FAQ

What are the formats Terrazzo supports?

Anything! When we talk about a “format,” it’s up to the plugin to name that format, and it can be any name it wants. A format is merely a query key that build() steps will query tokens by, so make it predictable and intuitive (file extensions are encouraged, e.g. css, js, json).

Can my plugin read from another plugin’s transform() step?

Yup! Just be sure you know what format that plugin is generating so you can query it. Also set enforce: “post” to run your plugin last so it can be sure the other plugin already ran.

Can I use any JavaScript in a plugin?

Yes, although you’ll get the most mileage out of avoiding Node.js code (such as reading from the filesystem). Terrazzo’s Plugin API is designed to work in any environment, including in a browser (like the Token Lab!). While there’s nothing stopping you from using Node.js behavior, by avoiding it you’ll have a more portable plugin that can run in any environment (e.g. a browser or a serverless function).

API

The full build process consists of the following steps, in order:

  1. config: Terrazzo finalizes the user config, and lets the plugin know the final settings (token location, lint options, user config, etc.)
  2. lint: the plugin lints the original tokens file and checks for errors
  3. transform: the token values are converted to formats (e.g. css, js, scss, json)
  4. build: gather relevant transforms to assemble output file(s)
  5. postBuild: run after all other steps (in case plugins want to introspect the final output).

In addition to the following steps, each plugin can also set additional options:

  1. name: This plugin’s name (useful for errors and debugging)
  2. enforce: When this plugin runs. Set to "pre" to run before all other plugins, "post" to run after all other plugins, or leave blank to run in the default order (array order).

name

The plugin provides its own name (useful for error messages and debugging).

export default function myPlugin() {
  return {
    name: "my-plugin",
  };
}

enforce

Set to "pre" if this plugin should run before all other plugins, "post" to run at the end, or undefined for it to run in the plugins array order. This is useful if this plugin relies on the output of other plugins (such as needing a transform it doesn’t generate).

export default function myPlugin() {
  return {
    enforce: "pre", // run before all other plugins
  };
}

config()

The config() hook fires after all plugins have been registered and the final config is resolved. This is handy when you need to grab a value from terrazzo.config.mjs (but note you can’t modify anything!).

export default function myPlugin() {
  let outDir;

  return {
    config(userConfig) {
      outDir = userConfig.outDir;
    },
  };
}
note

config() is read-only.

transform()

The transform hook can populate a format with transformed values. A format is a language that tokens will be written to, such as, but not limited to: css, scss, json, js, ts, and more.

import { rgb } from "culori";

export default function myPlugin() {
  return {
    async transform({ tokens, setTransform }) {
      setTransform("color.base.blue.500", {
        format: "js",
        localID: "color.base.blue.500",
        value: rgb(tokens["color.base.blue.500"].$value),
        mode: ".",
      });
      setTransform("color.base.blue.500", {
        format: "ts",
        localID: "color.base.blue.500",
        value: "ReturnType<typeof rgb>",
        mode: ".",
      });
    },
  };
}

Options

transform() takes a single object as its only parameter with the following options:

OptionTypeDescription
tokensObjectA shallow, read-only object of all tokens with IDs as keys.
setTransform(string, { format: string, localID?: string, value: string | Record<string, string>, mode?: string }) => voidSet a token value for an output format. Accepts a token ID along with format, localID, value, and mode that can all be queried for with getTransforms().
getTransforms({ format: string, id?: string | string[], $type?: string | string[], mode?: string | string[] }) => voidGet current token transforms (note that formats may be empty in this step if your plugin runs first)
astObjectA JSON AST that represents the original tokens file (good for pointing to a specific line in the source file in case of an error, but otherwise not useful).

build()

The build step is where a format’s values are read and converted into output file(s). Note that the build step does always have access to the original tokens, however, it’s advantageous to take advantage of any transforms that could save some work in this step.

export default function myPlugin() {
  return {
    async build({ tokens, getTransforms, outputFile }) {
      const output = [];

      output.push("const tokens = {");

      const colorTokens = getTransforms({
        format: "js",
        id: "color.*",
        mode: ".",
      });
      for (const token of colorTokens) {
        output.push(`  ${token.localID ?? token.token.id}: ${token.value},`);
      }
      output.push("};", "", "export default tokens;", "");

      outputFile("tokens.css", output.join("\n"));
    },
  };
}

Though outputFile() takes a string, you are free to use an AST and/or build your file(s) in any way you choose. Because Terrazzo supports any possible file output, it’s up to you to decide how you’d like to generate the file, so long as it’s a string at the end.

Options

build() takes a single object as its only parameter with the following options:

OptionTypeDescription
tokensObjectA shallow, read-only object of all tokens with IDs as keys.
getTransforms({ format: string, id?: string | string[], $type?: string | string[], mode?: string | string[] }) => voidGet final token transforms. Note that unlike transform(), when called in this step this list is complete and read-only.
outputFile(name: string, contents: string) => voidA callback to create an output file. This can be called multiple times if creating multiple files.
astObjectA JSON AST that represents the original tokens file (good for pointing to a specific line in the source file in case of an error, but otherwise not useful).
tip
  • getTransforms() will return all modes unless you filter with "mode": ".". You might see “duplicates” that aren’t really duplicates!
  • outputFile()’s name is relative to outDir in config. Prefer simple filenames when possible, though if your plugin works by creating directories, use POSIX-style paths ("my-dir/my-file.json") and Terrazzo will create directories for you.

buildEnd()

buildEnd is an optional step that is only useful for introspecting the files that were built. Though it’s too late to modify any tokens or output, you can see the final result of the build process.

Options
OptionTypeDescription
tokensObjectA shallow, read-only object of all tokens with IDs as keys.
outputFiles{name: string, contents: string, plugin: string, time: number}[]A read-only array of all the files generated, along with the plugin that made it and time of how long the build() step took.
getTransforms({ format: string, id?: string | string[], $type?: string | string[], mode?: string | string[] }) => voidGet final token transforms.
astObjectA JSON AST that represents the original tokens file (good for pointing to a specific line in the source file in case of an error, but otherwise not useful).

Normalized Token

The Terrazzo parser will create normalized tokens that have lots of additional metadata. Each normalized token has the following:

PropertyTypeDescription
idstringThis token’s global ID.
$typeTokenTypeA valid token $type (docs).
$descriptionstring | undefinedThe token $description, if provided.
$valueTokenValueThe token $value (docs). This will always be the final value (even if this is an alias of another token).
aliasOfTokenNormalized | undefinedThe token this aliases, if any (or undefined if this is its original value).
groupGroupInformation about this token’s immediate parent group (including sibling tokens).
modeRecord<string, TokenNormalized>A key–value map of mode name → mode value. Note: the mode "." will always exist, and will always point to the default value.
originalValueTokenThis token’s original value from the source file.
source{ node: ObjectNode, filename?: URL }Points to a file on disk as well as a Momoa AST node (including line number).