Custom transformer
Write a transformer for a new source language or DSL
A SayKit Transformer is the bridge between source code and messages. It tells the bucket which files it handles, how to extract messages from them, and how to rewrite macros into runtime calls.
The built-in @saykit/transform-js and @saykit/transform-jsx cover JavaScript, TypeScript, JSX, and TSX. If you want SayKit messages in another language (Vue SFCs, Svelte, MDX, an internal DSL), you write a transformer.
Writing a transformer is the most involved customisation in SayKit. Read messages and architecture first.
The shape
import type { Transformer } from '@saykit/config';
const transformer: Transformer = {
match(id: string): boolean {
/* ... */
},
extract(code: string, id: string): Message[] {
/* ... */
},
transform(code: string, id: string): string {
/* ... */
},
};Prop
Type
How the built-in transformers work
For reference, the JS transformer:
- Parses source with
@babel/parser(with'typescript'plugin). - Walks the AST with
@babel/traverse. - For each
Expression:- Tries to interpret it as a
saymacro (tagged template, orsay.plural/say.ordinal/say.selectcall). - If it is one, builds a
CompositeMessagefrom the AST.
- Tries to interpret it as a
- On
extract, collects everyCompositeMessageand converts each to theMessageshape (with ICU string, comments, references). - On
transform, replaces each matched expression with asay.call({ id, ...args })call expression.
The JSX transformer does the same plus a JSXElement visitor for <Say> components.
Reusing the building blocks
Most of the parsing work is reusable. @saykit/config/features/messages exposes:
CompositeMessage,LiteralMessage,ArgumentMessage,ChoiceMessage,ElementMessageassignSequenceIdentifiers(message, { current: 0 }), numbers positional placeholdersgenerateHash(text, context), produces the same 6-char id SayKit uses elsewhere
And @saykit/transform-js exposes its parser and generator:
import { parseExpression } from '@saykit/transform-js/parser';
import { generateSayCallExpression } from '@saykit/transform-js/generator';parseExpression(babelNode) recognises say macros in a Babel expression and returns a CompositeMessage.
generateSayCallExpression(message) turns it back into a say.call(...) Babel expression.
This makes it relatively cheap to write a transformer for a new language that embeds JS expressions, you reuse the JS recognition logic for inner expressions.
Sketch: a Vue SFC transformer
The general shape, in pseudocode:
import type { Transformer } from '@saykit/config';
import { parse as parseSfc } from '@vue/compiler-sfc';
import jsTransformer from '@saykit/transform-js';
export default function vue(): Transformer {
const js = jsTransformer();
return {
match(id) {
return id.endsWith('.vue');
},
extract(code, id) {
const { descriptor } = parseSfc(code);
const scripts = [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean);
const messages = scripts.flatMap((s) => js.extract(s!, id));
// TODO: also walk descriptor.template for {{ say`...` }} macros
return messages;
},
transform(code, id) {
const { descriptor } = parseSfc(code);
// Re-stitch the SFC with transformed <script>/<script setup>
// and a transformed <template>.
// ...
},
};
}The hard parts are usually:
- synchronous parsing, both
extractandtransformare sync, by contract - source maps, if you stitch a custom format back together, source map fidelity matters
- placeholder names, pull them out of identifier nodes when you can, fall back to positional
The extract ↔ transform contract
extract and transform see the same AST shape; they only differ in what they do with matched nodes:
extractreads them and appends to a list.transformrewrites them and returns the new source.
A common pattern: a single internal parser that runs once per macro, returning a CompositeMessage. Both extract and transform use that result, extract converts it to the catalogue shape, transform generates a say.call(...).
Synchronous-only
Both methods must be synchronous. This is a deliberate constraint:
- the CLI runs many files in parallel and benefits from straight-line execution
- bundler plugins (especially Babel) often expect synchronous transforms
- async I/O inside a transformer almost always means you're doing the wrong thing, do it in the formatter or outside the build
If you genuinely need async (e.g. for an external service lookup), pre-resolve it once at config load time and pass the data into the transformer factory.
Registering
import { defineConfig } from '@saykit/config';
import po from '@saykit/format-po';
import js from '@saykit/transform-js';
import vue from './my-transform-vue';
export default defineConfig({
locales: ['en', 'fr'],
buckets: [
{
include: ['src/**/*.{ts,vue}'],
output: 'src/locales/{locale}.{extension}',
formatter: po(),
transformer: [js(), vue()],
},
],
});Each file goes to the first transformer whose match() returns true, or, if multiple match, the bucket runs all of them and unions the results.
Next
- Architecture, how transformers fit into the pipeline
- Configuration, bucket and transformer shapes
- API reference, the underlying types