SayKit
Core Concepts

Extraction

How the saykit CLI scans your source and produces translation files

The saykit CLI scans your source files for macros, normalises them to ICU MessageFormat, and writes the source-locale translation file for each bucket. It's the only SayKit tool that touches your disk, the runtime never does.

pnpm saykit extract

What it does

For each bucket in your config the CLI:

  1. Globs all files matching include (and not exclude).
  2. Asks each transformer to parse each file and extract messages.
  3. Merges and de-duplicates messages across files. Identical text + context get one entry with all source references combined.
  4. Hashes each message into a 6-character id (unless you provided a custom one in a descriptor).
  5. Writes the source locale catalogue via the bucket's formatter, and creates an empty placeholder file for any locale that doesn't have one yet.

The end result is a set of files like:

src/locales/
  en.po         # source locale, freshly generated from your source
  en.d.po.ts    # auto-generated TS declaration for *.po imports
  fr.po         # other locales, left untouched (or created empty if missing)
  fr.d.po.ts

The .d.po.ts files are emitted next to each catalogue so that import en from './locales/en.po' type-checks even without a build plugin running. The .d.{extension}.ts name is the one TypeScript actually looks for, and non-JSON extensions need allowArbitraryExtensions — see typed messages.

Source-only writes

Extraction writes only the source locale (the first entry in locales). It never edits your other locale files.

  • The source catalogue is regenerated from your code on every run.
  • A locale that has no file yet is bootstrapped with an empty, header-only catalogue so a TMS like Weblate can register it.
  • Locales that already have a file are left completely untouched, no keys added, none removed.

This keeps diffs small (changing one string touches one file) and treats translated content as owned by your translation management system, not by extraction. You can run saykit extract as often as you like; your translators' work is never touched.

Fallback at load time

Because non-source files no longer carry the source strings, an untranslated (or missing) key is resolved through a fallback chain when the build plugin loads a catalogue, not at runtime. By default the chain ends at the source locale, so an untranslated key renders the source string. You can insert intermediate locales with fallbackLocales:

saykit.config.ts
fallbackLocales: {
  'en-NZ': ['en-GB'], // en-NZ → en-GB → en (source)
  'es-MX': 'es',      // es-MX → es → en (source)
},

The chain is baked into the emitted JS module at build time, so the runtime still loads a single locale. See Vite / Babel for how loading works.

Pruning other locales yourself

If your TMS does not tidy up after itself, run saykit clean. It removes entries from every non-source locale that no longer exist in the source catalogue, along with entries that were never translated. It only ever subtracts, missing keys are never added back.

Watch mode

For iterative development, use --watch:

pnpm saykit extract --watch

The CLI does an initial scan, then watches the working directory for changes. When a file inside any bucket changes, it re-extracts from that file and re-writes the catalogue. Removed files are also detected and their entries dropped.

Watch mode is debounced and bucket-aware, files outside any bucket's globs are ignored.

Logging

pnpm saykit extract             # default, concise output
pnpm saykit extract --verbose   # include per-file step logging
pnpm saykit extract --quiet     # suppress all logs

A normal run looks like:

🛠 Extracting Messages
  Scanning bucket: src/**/*.{ts,tsx}
    Found 42 file(s)
  Total extracted messages: 137
  Writing 137 messages to en
    Writing locale file for en to disk
    Skipping existing locale file for fr
  ✓ Extraction complete for bucket: src/**/*.{ts,tsx}

Buckets and multi-bucket projects

When you have multiple buckets, each is processed independently, in parallel for the initial scan, and independently for watch. Different buckets can use different formatters and transformers, and produce entirely separate translation files.

saykit.config.ts
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(),
  },
];

A message authored in both buckets ends up in both catalogues. That's usually what you want for shared strings, but if you'd rather keep them separate, narrow your include globs.

CI

For continuous integration, extract once and verify the result is clean:

- run: pnpm saykit extract
- run: git diff --exit-code

This fails the build if anyone forgot to run extraction, useful for keeping translation files in sync with code.

What gets generated

For each bucket output:

FilePurpose
{locale}.poThe catalogue, in whatever format the bucket's formatter produces
{locale}.d.po.tsGeneric TS declaration so import en from './locales/en.po' types

SayKit doesn't write a .gitignore — it's up to you which generated files to commit or ignore. The .po catalogues are canonical and should be committed; the .d.po.ts declarations are regenerated on every extraction, so you can either commit them (handy for CI type-checking) or ignore them via your project's .gitignore.

Next

On this page