Saykit uses formatters to read and write translation files. While the default PO format works for most projects, you can create custom formatters to support any file format you need.
A formatter is an object that defines how to parse and stringify translation messages:
interface Formatter {
extension : string ;
parse : ( content : string , context : { locale : string }) => Promise < Message []>;
stringify : ( messages : Message [], context : { locale : string }) => Promise < string >;
}
interface Message {
message : string ; // Source message text
translation ?: string ; // Translated text
id ?: string ; // Optional custom ID
context ?: string ; // Disambiguation context
comments : string []; // Translator comments
references : string []; // Source file locations
}
Properties
The file extension for this format (without the leading dot). extension : 'po' // generates .po files
extension : 'json' // generates .json files
extension : 'yaml' // generates .yaml files
Converts file content into an array of Message objects. Parameters:
content: The file content as a string
context.locale: The target locale being parsed
Returns: A promise resolving to an array of messagesThis method is called when:
Loading existing translations
Re-running extraction to preserve translations
Converts an array of Message objects into file content. Parameters:
messages: Array of messages to serialize
context.locale: The target locale being written
Returns: A promise resolving to the file content stringThis method is called when:
Generating new translation files
Updating existing files after extraction
The built-in PO formatter demonstrates a complete implementation:
import type { Formatter } from '@saykit/config' ;
import PO from 'pofile' ;
function createFormatter () : Formatter {
return {
extension: '.po' ,
async parse ( content , context ) {
const po = PO . parse ( content );
// Validate the file
if ( ! po . headers [ 'X-Generator' ]?. startsWith ( 'saykit' ))
throw new Error ( 'PO file was not generated by saykit' );
if ( po . headers . Language !== context . locale )
throw new Error ( 'PO file locale does not match' );
// Convert PO items to Message objects
return po . items . map (( item ) => {
const id = item . extractedComments
. find (( c ) => c . startsWith ( 'id:' ))
?. slice ( 3 );
const comments = item . extractedComments
. filter (( c ) => ! c . startsWith ( 'id:' ))
. map (( c ) => c . trim ());
return {
id ,
context: item . msgctxt ,
message: item . msgid ,
translation: item . msgstr [ 0 ],
comments ,
references: item . references ,
};
});
},
async stringify ( messages , context ) {
const po = new PO ();
// Set headers
po . headers [ 'Content-Type' ] = 'text/plain; charset=UTF-8' ;
po . headers [ 'Content-Transfer-Encoding' ] = '8bit' ;
po . headers . Language = context . locale ;
po . headers [ 'X-Generator' ] = 'saykit' ;
// Convert Message objects to PO items
for ( const message of messages ) {
const item = new PO . Item ();
item . msgid = message . message ;
if ( message . context ) item . msgctxt = message . context ;
item . msgstr = [ message . translation ?? '' ];
const comments = [];
if ( message . id ) comments . push ( `id: ${ message . id } ` );
if ( message . comments . length ) comments . push ( ... message . comments );
item . extractedComments = comments ;
item . references = message . references ;
po . items . push ( item );
}
return po . toString ();
},
};
}
export default createFormatter ;
The PO formatter is available as @saykit/format-po and is used by default when no formatter is specified.
Here’s a simple JSON formatter for flat key-value translation files:
import type { Formatter , Message } from '@saykit/config' ;
function createJSONFormatter () : Formatter {
return {
extension: 'json' ,
async parse ( content , context ) {
const data = JSON . parse ( content );
const messages : Message [] = [];
for ( const [ key , value ] of Object . entries ( data )) {
if ( typeof value === 'string' ) {
messages . push ({
id: key ,
message: value ,
translation: value ,
comments: [],
references: [],
});
}
}
return messages ;
},
async stringify ( messages , context ) {
const data : Record < string , string > = {};
for ( const message of messages ) {
const key = message . id || message . message ;
data [ key ] = message . translation || message . message ;
}
return JSON . stringify ( data , null , 2 );
},
};
}
export default createJSONFormatter ;
Output Example
{
"welcome.greeting" : "Hello, {name}!" ,
"cart.item_count" : "{count, plural, one {# item} other {# items}}" ,
"loading" : "Loading..."
}
A YAML formatter with nested structure and metadata:
import type { Formatter , Message } from '@saykit/config' ;
import YAML from 'yaml' ;
interface YAMLMessage {
message : string ;
translation ?: string ;
context ?: string ;
comments ?: string [];
}
function createYAMLFormatter () : Formatter {
return {
extension: 'yaml' ,
async parse ( content , context ) {
const data = YAML . parse ( content ) as Record < string , YAMLMessage >;
const messages : Message [] = [];
for ( const [ id , item ] of Object . entries ( data )) {
messages . push ({
id ,
message: item . message ,
translation: item . translation ,
context: item . context ,
comments: item . comments || [],
references: [],
});
}
return messages ;
},
async stringify ( messages , context ) {
const data : Record < string , YAMLMessage > = {};
for ( const message of messages ) {
const id = message . id || message . message ;
data [ id ] = {
message: message . message ,
... ( message . translation && { translation: message . translation }),
... ( message . context && { context: message . context }),
... ( message . comments . length && { comments: message . comments }),
};
}
return YAML . stringify ( data );
},
};
}
export default createYAMLFormatter ;
Output Example
welcome.greeting :
message : "Hello, {name}!"
translation : "Hello, {name}!"
comments :
- "Shown on the home page"
cart.empty :
message : "Your cart is empty"
translation : "Your cart is empty"
context : "shopping"
loading :
message : "Loading..."
translation : "Loading..."
Apply your formatter in the bucket configuration:
import { defineConfig } from '@saykit/config' ;
import createJSONFormatter from './formatters/json' ;
export default defineConfig ({
sourceLocale: 'en' ,
locales: [ 'en' , 'fr' , 'es' ] ,
buckets: [
{
include: [ 'src/**/*.tsx' ],
output: 'src/locales/{locale}/messages.{extension}' ,
formatter: createJSONFormatter (),
},
] ,
}) ;
Use different formatters for different buckets:
import { defineConfig } from '@saykit/config' ;
import createJSONFormatter from './formatters/json' ;
import createYAMLFormatter from './formatters/yaml' ;
export default defineConfig ({
sourceLocale: 'en' ,
locales: [ 'en' , 'fr' ] ,
buckets: [
{
include: [ 'src/components/**/*.tsx' ],
output: 'locales/{locale}/components.{extension}' ,
formatter: createJSONFormatter (),
},
{
include: [ 'src/pages/**/*.tsx' ],
output: 'locales/{locale}/pages.{extension}' ,
formatter: createYAMLFormatter (),
},
] ,
}) ;
Share your formatter as an npm package:
Create Package Structure
@yourorg/saykit-format-json/
├── src/
│ └── index.ts
├── package.json
└── tsconfig.json
Export Factory Function
import type { Formatter } from '@saykit/config' ;
export default function createJSONFormatter () : Formatter {
return {
extension: 'json' ,
async parse ( content , context ) { /* ... */ },
async stringify ( messages , context ) { /* ... */ },
};
}
Configure Package
{
"name" : "@yourorg/saykit-format-json" ,
"version" : "1.0.0" ,
"type" : "module" ,
"main" : "./dist/index.js" ,
"types" : "./dist/index.d.ts" ,
"peerDependencies" : {
"@saykit/config" : "^1.0.0"
}
}
Use in Projects
npm install @yourorg/saykit-format-json
import { defineConfig } from '@saykit/config' ;
import createJSONFormatter from '@yourorg/saykit-format-json' ;
export default defineConfig ({
sourceLocale: 'en' ,
locales: [ 'en' , 'fr' ] ,
buckets: [
{
include: [ 'src/**/*.tsx' ],
output: 'locales/{locale}.{extension}' ,
formatter: createJSONFormatter (),
},
] ,
}) ;
Error Handling
Implement validation and clear error messages:
async parse ( content , context ) {
let data ;
try {
data = JSON . parse ( content );
} catch ( error ) {
throw new Error ( `Invalid JSON in ${ context . locale } translation file` );
}
if ( ! data || typeof data !== 'object' ) {
throw new Error (
`Expected object in ${ context . locale } translation file, got ${ typeof data } `
);
}
// Validate structure
const messages : Message [] = [];
for ( const [ key , value ] of Object . entries ( data )) {
if ( typeof value !== 'string' ) {
console . warn (
`Skipping non-string value for key " ${ key } " in ${ context . locale } `
);
continue ;
}
messages . push ({ /* ... */ });
}
return messages ;
}
Always validate input in the parse method. Translation files may be manually edited by translators and could contain unexpected formats or errors.
Best Practices
Preserve Metadata : Store comments, context, and references when possible
Handle Empty Values : Support messages without translations (for new extractions)
Validate Locale : Check that the file locale matches the expected locale
Sort Output : Keep consistent ordering for better version control diffs
Format Nicely : Use proper indentation and line breaks for human readability
Version Files : Include format version or generator info in output
Document Format : Provide examples and explain any custom conventions
Next Steps