Skip to content

Commit 4a11c3d

Browse files
dprevost-LMIcpojer
andauthored
feat: use --output-file to output translations in a single file (#66)
Co-authored-by: Christoph Nakazawa <[email protected]>
1 parent 5dc1a67 commit 4a11c3d

File tree

5 files changed

+366
-22
lines changed

5 files changed

+366
-22
lines changed

CONTRIBUTING.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Contributing to fbtee
2+
3+
Thank you for your interest in contributing to fbtee! This guide will help you get started with development.
4+
5+
## Prerequisites
6+
7+
- **Node.js**: Version 24.0.0 or higher
8+
- **pnpm**: Version 10.0.0 or higher
9+
10+
```bash
11+
pnpm env use --global 24
12+
nvm use 24
13+
```
14+
15+
## Getting Started
16+
17+
To set up the project and run tests:
18+
19+
```bash
20+
pnpm install
21+
22+
# Build everything required for unit tests
23+
pnpm build:all
24+
25+
pnpm test
26+
```
27+
28+
The example build step is **required** before running tests, as the test suite depends on the generated translation artifacts.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ export default {
114114
By default, **fbtee** expects your source code to be anywhere within your project, your translations in a `translations` folder, and the generated translations in `src/translations`. You can customize these paths using command line arguments:
115115

116116
- The `--translations` parameter can be specified to `fbtee translate` to customize the path to the input translation files.
117-
- `--output-dir` can be specified to define where the output translations should be written so they can be loaded in your app. You can also use `--out` parameter to output a single file such as `translations.json`, which can be used directly in your app without loading individual translation files.
117+
- The `--output-dir` parameter defines where the output translation files per language should be written (one file per locale), so they can be lazy loaded in your app.
118+
- The `--output-file` parameter outputs all translations in a single combined file, which can be used directly in your app without loading individual files.
118119

119120
If you want to use different paths, it is recommended to define custom commands in your `package.json`:
120121

packages/babel-plugin-fbtee/src/bin/__tests__/translate-test.tsx

Lines changed: 284 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
1-
import { afterEach, describe, it, jest } from '@jest/globals';
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
readFileSync,
5+
rmSync,
6+
writeFileSync,
7+
} from 'node:fs';
8+
import { join } from 'node:path';
9+
import { afterEach, beforeEach, describe, it, jest } from '@jest/globals';
210
import { jsCodeNonASCIICharSerializer } from '../../__tests__/FbtTestUtil.tsx';
3-
import { Options, processJSON } from '../translateUtils.tsx';
11+
import {
12+
LocaleToHashToTranslationResult,
13+
Options,
14+
processFiles,
15+
processJSON,
16+
processSingleFile,
17+
writeOutput,
18+
writeSingleOutput,
19+
} from '../translateUtils.tsx';
420

521
expect.addSnapshotSerializer(jsCodeNonASCIICharSerializer);
622

@@ -548,4 +564,270 @@ describe('translate-test.js', () => {
548564
});
549565
}
550566
});
567+
568+
describe('processFiles and processSingleFile', () => {
569+
const __dirname = import.meta.dirname;
570+
571+
const mockSourceStrings = {
572+
phrases: [
573+
{
574+
filename: 'src/example/Example.js',
575+
hashToLeaf: {
576+
abc123: {
577+
desc: 'greeting',
578+
text: 'Hello',
579+
},
580+
},
581+
jsfbt: {
582+
m: [],
583+
t: {
584+
desc: 'greeting',
585+
text: 'Hello',
586+
tokenAliases: {},
587+
},
588+
},
589+
loc: {
590+
end: { column: 10, line: 1 },
591+
start: { column: 0, line: 1 },
592+
},
593+
project: 'test',
594+
},
595+
],
596+
};
597+
598+
const mockTranslations = {
599+
es_LA: {
600+
'fb-locale': 'es_LA',
601+
translations: {
602+
abc123: {
603+
tokens: [],
604+
translations: [{ translation: 'Hola', variations: {} }],
605+
types: [],
606+
},
607+
},
608+
},
609+
fr_FR: {
610+
'fb-locale': 'fr_FR',
611+
translations: {
612+
abc123: {
613+
tokens: [],
614+
translations: [{ translation: 'Bonjour', variations: {} }],
615+
types: [],
616+
},
617+
},
618+
},
619+
};
620+
621+
const setupTestDir = (dirName: string) => {
622+
const testDir = join(__dirname, dirName);
623+
624+
beforeEach(() => {
625+
if (existsSync(testDir)) {
626+
rmSync(testDir, { force: true, recursive: true });
627+
}
628+
mkdirSync(testDir, { recursive: true });
629+
});
630+
631+
afterEach(() => {
632+
if (existsSync(testDir)) {
633+
rmSync(testDir, { force: true, recursive: true });
634+
}
635+
});
636+
637+
return testDir;
638+
};
639+
640+
describe('processFiles (--output-dir option)', () => {
641+
const testDir = setupTestDir('__test_process_files__');
642+
643+
it('should process multiple translation files and combine them', async () => {
644+
const sourceFile = join(testDir, 'source_strings.json');
645+
const frFile = join(testDir, 'fr_FR.json');
646+
const esFile = join(testDir, 'es_LA.json');
647+
648+
writeFileSync(sourceFile, JSON.stringify(mockSourceStrings));
649+
writeFileSync(frFile, JSON.stringify(mockTranslations.fr_FR));
650+
writeFileSync(esFile, JSON.stringify(mockTranslations.es_LA));
651+
652+
const result = await processFiles(sourceFile, [frFile, esFile], {
653+
hashModule: false,
654+
jenkins: true,
655+
strict: false,
656+
});
657+
658+
expect(result).toEqual({
659+
es_LA: {
660+
'35E3uI': 'Hola',
661+
},
662+
fr_FR: {
663+
'35E3uI': 'Bonjour',
664+
},
665+
});
666+
});
667+
});
668+
669+
describe('processSingleFile (--output-file option)', () => {
670+
const testDir = setupTestDir('__test_single_file__');
671+
672+
it('should process files and return combined result for single file output', async () => {
673+
const sourceFile = join(testDir, 'source_strings.json');
674+
const esFile = join(testDir, 'es_LA.json');
675+
const frFile = join(testDir, 'fr_FR.json');
676+
677+
writeFileSync(sourceFile, JSON.stringify(mockSourceStrings));
678+
writeFileSync(esFile, JSON.stringify(mockTranslations.es_LA));
679+
writeFileSync(frFile, JSON.stringify(mockTranslations.fr_FR));
680+
681+
const result = await processSingleFile(sourceFile, [esFile, frFile], {
682+
hashModule: false,
683+
jenkins: true,
684+
strict: false,
685+
});
686+
687+
expect(result).toEqual({
688+
es_LA: {
689+
'35E3uI': 'Hola',
690+
},
691+
fr_FR: {
692+
'35E3uI': 'Bonjour',
693+
},
694+
});
695+
});
696+
697+
it('should handle same data as processFiles (verifying they use same logic)', async () => {
698+
const sourceFile = join(testDir, 'source.json');
699+
const frFile = join(testDir, 'fr.json');
700+
701+
writeFileSync(sourceFile, JSON.stringify(mockSourceStrings));
702+
writeFileSync(frFile, JSON.stringify(mockTranslations.fr_FR));
703+
704+
const resultFromProcessFiles = await processFiles(
705+
sourceFile,
706+
[frFile],
707+
{
708+
hashModule: false,
709+
jenkins: true,
710+
strict: false,
711+
},
712+
);
713+
714+
const resultFromProcessSingleFile = await processSingleFile(
715+
sourceFile,
716+
[frFile],
717+
{ hashModule: false, jenkins: true, strict: false },
718+
);
719+
720+
expect(resultFromProcessFiles).toEqual(resultFromProcessSingleFile);
721+
expect(resultFromProcessFiles).toEqual({
722+
fr_FR: {
723+
'35E3uI': 'Bonjour',
724+
},
725+
});
726+
});
727+
});
728+
729+
describe('CLI file writing (--output-file and --output-dir)', () => {
730+
const testDir = setupTestDir('__test_output__');
731+
732+
it('should write single output file with --output-file option', async () => {
733+
const mockData: LocaleToHashToTranslationResult = {
734+
de_DE: {
735+
hash1: 'Hallo',
736+
hash2: 'Auf Wiedersehen',
737+
},
738+
es_LA: {
739+
hash1: 'Hola',
740+
hash2: 'Adiós',
741+
},
742+
fr_FR: {
743+
hash1: 'Bonjour',
744+
hash2: 'Au revoir',
745+
},
746+
};
747+
748+
const outputFilePath = join(testDir, 'translations.json');
749+
writeSingleOutput(outputFilePath, mockData);
750+
751+
expect(existsSync(outputFilePath)).toBe(true);
752+
const fileContent = JSON.parse(readFileSync(outputFilePath, 'utf8'));
753+
expect(fileContent).toEqual(mockData);
754+
});
755+
756+
it('should write multiple files with --output-dir option', async () => {
757+
const mockData: LocaleToHashToTranslationResult = {
758+
fr_FR: {
759+
hash1: 'Salut',
760+
hash2: 'Merci',
761+
},
762+
ja_JP: {
763+
hash1: 'こんにちは',
764+
hash2: 'ありがとう',
765+
},
766+
};
767+
768+
const outputDir = join(testDir, 'translations');
769+
writeOutput(outputDir, mockData);
770+
771+
expect(existsSync(join(outputDir, 'fr_FR.json'))).toBe(true);
772+
expect(existsSync(join(outputDir, 'ja_JP.json'))).toBe(true);
773+
774+
const frContent = JSON.parse(
775+
readFileSync(join(outputDir, 'fr_FR.json'), 'utf8'),
776+
);
777+
expect(frContent).toEqual({ fr_FR: mockData.fr_FR });
778+
779+
const jaContent = JSON.parse(
780+
readFileSync(join(outputDir, 'ja_JP.json'), 'utf8'),
781+
);
782+
expect(jaContent).toEqual({ ja_JP: mockData.ja_JP });
783+
});
784+
785+
it('should create nested directories for --output-file option', async () => {
786+
const mockData: LocaleToHashToTranslationResult = {
787+
en_US: { test: 'Hello' },
788+
};
789+
790+
const nestedPath = join(
791+
testDir,
792+
'deeply',
793+
'nested',
794+
'path',
795+
'output.json',
796+
);
797+
798+
writeSingleOutput(nestedPath, mockData);
799+
800+
expect(existsSync(nestedPath)).toBe(true);
801+
const content = JSON.parse(readFileSync(nestedPath, 'utf8'));
802+
expect(content).toEqual(mockData);
803+
});
804+
805+
it('should handle empty translations gracefully', async () => {
806+
const mockData: LocaleToHashToTranslationResult = {};
807+
808+
const outputFilePath = join(testDir, 'empty.json');
809+
writeSingleOutput(outputFilePath, mockData);
810+
811+
expect(existsSync(outputFilePath)).toBe(true);
812+
const content = JSON.parse(readFileSync(outputFilePath, 'utf8'));
813+
expect(content).toEqual({});
814+
expect(Object.keys(content)).toHaveLength(0);
815+
});
816+
817+
it('should format JSON output with proper indentation', async () => {
818+
const mockData: LocaleToHashToTranslationResult = {
819+
en_US: {
820+
hash1: 'Test',
821+
},
822+
};
823+
824+
const outputFilePath = join(testDir, 'formatted.json');
825+
writeSingleOutput(outputFilePath, mockData);
826+
827+
expect(() =>
828+
JSON.parse(readFileSync(outputFilePath, 'utf8')),
829+
).not.toThrow();
830+
});
831+
});
832+
});
551833
});

0 commit comments

Comments
 (0)