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 extractWhat it does
For each bucket in your config the CLI:
- Globs all files matching
include(and notexclude). - Asks each transformer to parse each file and extract messages.
- Merges and de-duplicates messages across files. Identical text + context get one entry with all source references combined.
- Hashes each message into a 6-character id (unless you provided a custom one in a descriptor).
- 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.tsThe .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:
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 --watchThe 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 logsA 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.
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-codeThis 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:
| File | Purpose |
|---|---|
{locale}.po | The catalogue, in whatever format the bucket's formatter produces |
{locale}.d.po.ts | Generic 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
- Formats, PO and writing your own
- Configuration, bucket fields and the output template
- CLI reference, every command and flag