SayKit
Core Concepts

Architecture

How SayKit's runtime, config, transformers, formatters, and plugins fit together

SayKit is a small toolkit, not a framework. It has a few pieces that compose cleanly, once you've seen them once, everything else in the docs hangs off this picture.

The five pieces

Runtime

The Say class. Holds locales, messages, and the active locale. Formats compiled message descriptors.

Config

A typed saykit.config.ts. Tells the CLI and plugins which files to scan, which transformers to apply, and where to write translations.

Transformer

Knows how to parse a source language. Extracts messages from source, and rewrites macros to runtime calls.

Formatter

Knows how to read and write a translation file format (PO by default).

Plugin

Glue for your bundler. unplugin-saykit covers Vite/Rollup/etc; babel-plugin-saykit covers Babel-driven pipelines.

How they fit together

Two distinct workflows share the same config:

Extraction (you run this)

┌──────────────┐
│  source.ts   │  contains `say` / <Say> macros
└──────┬───────┘

       │  saykit extract

┌──────────────┐    ┌────────────┐    ┌──────────────┐
│  Transformer │ →  │  Formatter │ →  │ locales/*.po │
│  (extract)   │    │ (stringify)│    │  on disk     │
└──────────────┘    └────────────┘    └──────────────┘

The CLI reads saykit.config.ts, walks the bucket globs, asks each transformer to extract messages, and asks the formatter to write them to disk. New runs reconcile against existing files, your translations are never overwritten.

Build (your bundler runs this)

┌──────────────┐
│  source.ts   │  contains `say` / <Say> macros
└──────┬───────┘

       │  bundler + saykit plugin

┌──────────────┐                ┌─────────────────────┐
│  Transformer │  rewrites →    │ source with runtime │
│  (transform) │                │ say.call(...) calls │
└──────────────┘                └─────────┬───────────┘

┌──────────────┐                ┌─────────▼───────────┐
│  *.po import │  rewritten →   │ JS module exporting │
│              │  via Formatter │ { id: "string" }    │
└──────────────┘                └─────────────────────┘

The build-tool plugin does two passes:

  1. Transform every file in a bucket's include, macros become small say.call(...) invocations.
  2. Load imports that match a bucket's output path (e.g. ./locales/en.po) and turn them into a JS module exporting the compiled message catalogue.

At runtime, no extractor is shipped. There is no babel runtime, no string table, just Say plus your messages.

The runtime side

A Say instance is just a container for locales and messages, plus a few utility methods:

import { Say } from 'saykit';

const say = new Say({
  locales: ['en', 'fr'],
  messages: { en, fr },
});

say.activate('en');

say.call({ id: 'abc123', name: 'Ada' });
// → "Hello, Ada!"

You almost never write say.call(...) by hand, the build transform generates it. What you write is:

say`Hello, ${name}!`;

The transformer takes that tagged template, extracts the literal text plus the placeholder, computes a stable hash id, and rewrites the source to call say.call({ id: '...', name }).

See runtime for the full API and messages for what's authorable.

Buckets

A bucket is a unit of "files in → translations out". A single config can have multiple buckets, each with its own globs, output path, formatter, and transformers:

buckets: [
  {
    include: ['src/app/**/*.{ts,tsx}'],
    output: 'src/locales/{locale}.{extension}',
    formatter: po(),
    transformer: [js(), jsx()],
  },
  {
    include: ['src/emails/**/*.ts'],
    output: 'src/emails/locales/{locale}.{extension}',
    formatter: po(),
    transformer: js(),
  },
];

This is useful when:

  • different surface areas need different translation files (email vs UI)
  • different parts of your code use different source languages
  • you want to keep server-only messages out of the client bundle

See configuration for the bucket fields and extraction for how the CLI uses them.

Identifiers

Every extracted message gets a stable identifier. By default it's a 6-character hash derived from the ICU message text plus an optional context:

sha256("Hello, {name}!" + "" + "")  →  truncate 6 chars → "Zk1aQv"

The same message text plus context always produces the same id, regardless of file, position, or time. That makes the system robust to:

  • moving messages between files
  • formatting / whitespace changes
  • multiple files using the same message

You can override this with a descriptor if you want a human-readable id or need to disambiguate identical strings with different meanings.

What ships at runtime

Aside from the transformer-emitted say.call(...) invocations, only two things end up in your bundle:

  • the saykit runtime (small, dependency-light apart from ICU MessageFormat)
  • whichever integration you're using (@saykit/react, @saykit/carbon, …)

Translation catalogues are emitted as plain JS modules, tree-shakable, code-split-able, dynamically importable. Locales you never load never end up in your bundle.

Where to next?

On this page