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:
- Transform every file in a bucket's
include, macros become smallsay.call(...)invocations. - Load imports that match a bucket's
outputpath (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
saykitruntime (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.