Skip to content

Commit

Permalink
Merge pull request #1 from BeTomorrow/develop
Browse files Browse the repository at this point in the history
Implementation of typegen
fdrault authored Dec 14, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
2 parents b83ccc0 + 80c93e9 commit 632ee62
Showing 24 changed files with 7,306 additions and 0 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Unit Tests

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
Jest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Install Package
run: npm ci --force
- name: Run Package Unit tests
run: npm test
2 changes: 2 additions & 0 deletions bin/i18n-typegen
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require("../dist/index.js");
9 changes: 9 additions & 0 deletions i18n-type.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"input": {
"format": "flatten",
"path": "./i18n/en.json"
},
"output": {
"path": "./i18n/translations.d.ts"
}
}
6 changes: 6 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"greeting": "Hello {{firstName}} {{familyName}}!",
"duration.day.one": "1 day",
"duration.day.other": "{{count}} days",
"duration.day.zero": "0 day"
}
25 changes: 25 additions & 0 deletions i18n/translations.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// prettier-ignore

/**
* Generated by i18n-type-generator
* https://github.com/BeTomorrow/i18n-type-generator
*/

declare module "translations" {
type Translations = {
"greeting": { firstName: string; familyName: string };
"duration.day": { count: number };
};

type TranslationKeys = keyof Translations;

type TranslationFunctionArgs<T extends TranslationKeys> = T extends TranslationKeys
? Translations[T] extends undefined
? [key: T]
: [key: T, interpolates: Translations[T]]
: never;

type TranslationFunction = <T extends TranslationKeys>(...args: TranslationFunctionArgs<T>) => string;

export { TranslationFunction, TranslationFunctionArgs, TranslationKeys };
}
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
};
6,670 changes: 6,670 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@betomorrow/i18n-typegen",
"version": "1.0.0",
"description": "Generate TS type for your translations keys and interpolation values",
"main": "dist/index.js",
"bin": "bin/i18n-typegen",
"scripts": {
"start": "ts-node src/index.ts",
"test": "jest",
"build": "tsc -p . && cp ./src/templates/translations.mustache ./dist/templates/translations.mustache"
},
"repository": {
"type": "git",
"url": "git+https://github.com/BeTomorrow/i18n-type-generator.git"
},
"keywords": [
"i18n",
"type",
"keys",
"interpolation"
],
"author": "Fabien Drault",
"license": "MIT",
"bugs": {
"url": "https://github.com/BeTomorrow/i18n-type-generator/issues"
},
"homepage": "https://github.com/BeTomorrow/i18n-type-generator#readme",
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.11",
"@types/mustache": "^4.2.5",
"@types/node": "^20.10.4",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"typescript": "^5.3.3"
},
"dependencies": {
"commander": "^11.1.0",
"fs-extra": "^11.2.0",
"mustache": "^4.2.0"
},
"files": [
"dist/",
"bin/"
]
}
6 changes: 6 additions & 0 deletions src/command/codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Configuration } from "../config/config-loader";
import { generateType } from "../templates/generate-type";

export function codegen(configuration: Configuration) {
return generateType(configuration);
}
29 changes: 29 additions & 0 deletions src/command/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as fs from "fs";
import { Configuration } from "../config/config-loader";

export function init() {
const configurationFilename = "i18n-type.config.json";
const defaultConfiguration: Configuration = {
input: {
format: "nested",
path: "./i18n/en.json",
},
output: {
path: "./i18n/translations.d.ts",
},
};
console.log(`Initialize config file ${configurationFilename}\n`);
writeDefaultConfiguration(defaultConfiguration, configurationFilename);
}

function writeDefaultConfiguration(config: Configuration, path: string) {
const text = JSON.stringify(config, null, 2);
try {
fs.writeFileSync(path, text);
console.log(`Default configuration: \n\
${text}\n`);
} catch (error) {
console.error(`Error writing the i18n type: ${(error as Error).message}`);
process.exit(1);
}
}
26 changes: 26 additions & 0 deletions src/config/config-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as fs from "fs";

export function readConfigFile(configPath: string): Configuration {
try {
const configData = fs.readFileSync(configPath, "utf-8");
return JSON.parse(configData);
} catch (error) {
console.error(
'Missing configuration file. \n\
Run "npm run i18n-typegen init" to generate one\n'
);
process.exit(1);
}
}

type InputFormat = "flatten" | "nested";

export interface Configuration {
input: {
format: InputFormat;
path: string;
};
output: {
path: string;
};
}
44 changes: 44 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as commander from "commander";
import * as path from "path";
import { codegen } from "./command/codegen";
import { init } from "./command/init";
import { readConfigFile } from "./config/config-loader";

function main() {
const program = new commander.Command();

program
.version("1.0.0")
.description(
"Generate TS type for your translation keys and interpolations"
);

program
.command("codegen")
.option(
"-c, --config <path>",
"Path to the config file",
"i18n-type.config.json"
)
.description("Generate i18n types")
.action((cmd) => {
const configPath = path.resolve(cmd.config);
const config = readConfigFile(configPath);

codegen(config);
});

program
.command("init")
.description("Initialize i18n-config file")
.action(() => init());

program.parse(process.argv);

// If no command is specified, show help
if (!process.argv.slice(2).length) {
program.help();
}
}

main();
47 changes: 47 additions & 0 deletions src/templates/generate-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as fs from "fs";
import * as fsExtra from "fs-extra";
import Mustache from "mustache";
import * as path from "path";
import { Configuration } from "../config/config-loader";
import { generateTemplateData } from "../translation/generate-template-data";
import { loadWordings } from "../wording/wording-loader";

function openTemplate() {
try {
const templatePath = path.join(__dirname, "translations.mustache");
return fs.readFileSync(templatePath, "utf-8");
} catch (error) {
console.error(
`Error reading or parsing the template file: ${(error as Error).message}`
);
process.exit(1);
}
}

function writeFile(text: string, path: string) {
try {
fsExtra.outputFileSync(path, text);
} catch (error) {
console.error(`Error writing the i18n type: ${(error as Error).message}`);
process.exit(1);
}
}

const utilityChar = {
OPEN_BRACE: "{",
CLOSE_BRACE: "}",
};

export function generateType(configuration: Configuration) {
const template = openTemplate();

const wordings = loadWordings(configuration);
const templateData = generateTemplateData(wordings);

const generatedType = Mustache.render(template, {
keys: templateData,
...utilityChar,
});

writeFile(generatedType, configuration.output.path);
}
10 changes: 10 additions & 0 deletions src/templates/template-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface InterpolationTemplateData {
name: string;
type: "string" | "number";
last?: boolean;
}

export interface WordingEntryTemplateData {
key: string;
interpolations: InterpolationTemplateData[];
}
26 changes: 26 additions & 0 deletions src/templates/translations.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// prettier-ignore

/**
* Generated by i18n-type-generator
* https://github.com/BeTomorrow/i18n-typegen
*/

declare module "translations" {
type Translations = {
{{#keys}}
"{{key}}": {{#interpolations.length}}{{OPEN_BRACE}}{{#interpolations}} {{name}}: {{type}}{{^last}};{{/last}} {{/interpolations}}{{CLOSE_BRACE}}{{/interpolations.length}}{{^interpolations.length}}undefined{{/interpolations.length}};
{{/keys}}
};

type TranslationKeys = keyof Translations;

type TranslationFunctionArgs<T extends TranslationKeys> = T extends TranslationKeys
? Translations[T] extends undefined
? [key: T]
: [key: T, interpolates: Translations[T]]
: never;

type TranslationFunction = <T extends TranslationKeys>(...args: TranslationFunctionArgs<T>) => string;

export { TranslationFunction, TranslationFunctionArgs, TranslationKeys };
}
19 changes: 19 additions & 0 deletions src/translation/find-interpolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { findInterpolations } from "./find-interpolation";

test("Find double bracket interpolation", () => {
const input = "Hello {{name}} !";
const interpolations = findInterpolations(input);
expect(interpolations).toEqual(["name"]);
});

test("Find %{value} interpolation", () => {
const input = "Hello %{name} !";
const interpolations = findInterpolations(input);
expect(interpolations).toEqual(["name"]);
});

test("Find multiple interpolations", () => {
const input = "Hello {{firstname}} %{familyName}";
const interpolations = findInterpolations(input);
expect(interpolations).toEqual(["firstname", "familyName"]);
});
19 changes: 19 additions & 0 deletions src/translation/find-interpolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Find all interpolation inside the double bracket.
* Support i18n-js format {{value}} and %{value}
* "Hello {{firstname}} *{familyName}" will return ["firstname", "familyName"]
*/
export const findInterpolations = (translation: string) => {
const doubleBraceRegexp = /{{(.*?)}}/g;
const percentBraceRegexp = /%{(.*?)}/g;
const matches: string[] = [];
let match;

while ((match = doubleBraceRegexp.exec(translation)) !== null) {
matches.push(match[1]);
}
while ((match = percentBraceRegexp.exec(translation)) !== null) {
matches.push(match[1]);
}
return matches;
};
80 changes: 80 additions & 0 deletions src/translation/generate-template-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { generateTemplateData } from "./generate-template-data";

describe("generateType", () => {
it("should handle pluralization correctly", () => {
const translations = {
"day.one": "1 day",
"day.other": "{{count}} days",
"day.zero": "0 day",
};

const result = generateTemplateData(translations, { detectPlurial: true });

expect(result).toHaveLength(1);

expect(result[0]).toEqual({
key: "day",
interpolations: [{ name: "count", type: "number", last: true }],
});
});

it("should ignore pluralization when disabled", () => {
const translations = {
"day.one": "1 day",
"day.other": "{{count}} days",
"day.zero": "0 day",
};

const result = generateTemplateData(translations, { detectPlurial: false });

expect(result).toHaveLength(3);

expect(result[0]).toEqual({
key: "day.one",
interpolations: [],
});
expect(result[1]).toEqual({
key: "day.other",
interpolations: [{ name: "count", type: "string", last: true }],
});
});

it("should handle multiple interpolations correctly", () => {
const translations = {
greeting: "Hello {{firstName}} {{familyName}}",
};

const result = generateTemplateData(translations);

expect(result).toHaveLength(1);

expect(result[0]).toEqual({
key: "greeting",
interpolations: [
{ name: "firstName", type: "string" },
{ name: "familyName", type: "string", last: true },
],
});
});

it("should handle mixed of pluralization and interpolations correctly", () => {
const translations = {
"day.one": "1 {{mood}} day",
"day.other": "{{count}} {{moods}} days",
"day.zero": "0 {{mood}} day",
};

const result = generateTemplateData(translations);

expect(result).toHaveLength(1);

expect(result[0]).toEqual({
key: "day",
interpolations: [
{ name: "mood", type: "string" },
{ name: "count", type: "number" },
{ name: "moods", type: "string", last: true },
],
});
});
});
116 changes: 116 additions & 0 deletions src/translation/generate-template-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
InterpolationTemplateData,
WordingEntryTemplateData,
} from "../templates/template-type";
import { findInterpolations } from "./find-interpolation";
import { isEnumerable } from "./is-enumerable";

type WordingKey = string;
type Translation = string;

interface WordingEntry {
key: WordingKey;
interpolations: Map<string, InterpolationType>;
}

interface GeneratorConfiguration {
detectPlurial: boolean;
detectInterpolation: boolean;
}

type InterpolationType = "string" | "number";

const defaultConfiguration = {
detectPlurial: true,
detectInterpolation: true,
};

export function generateTemplateData(
translations: Record<WordingKey, Translation>,
overwriteConfiguration: Partial<GeneratorConfiguration> = {}
): WordingEntryTemplateData[] {
const config = { ...defaultConfiguration, ...overwriteConfiguration };
const entries: Map<WordingKey, WordingEntry> = new Map();

(Object.entries(translations) as [WordingKey, Translation][]).forEach(
([key, translation]) => {
const entry = processTranslation(key, translation, entries, config);
entries.set(entry.key, entry);
}
);
return mapToTemplateData(entries);
}

function mapToTemplateData(
entries: Map<string, WordingEntry>
): WordingEntryTemplateData[] {
return Array.from(entries.values()).map((it) => ({
key: it.key,
interpolations: interpolationsMapToTemplate(it.interpolations),
}));
}

function interpolationsMapToTemplate(
interpolations: Map<string, InterpolationType>
) {
const interpolationArray: InterpolationTemplateData[] = Array.from(
interpolations.entries()
).map(([name, type]) => ({ name, type }));
if (interpolationArray.length > 0)
interpolationArray[interpolationArray.length - 1].last = true;
return interpolationArray;
}

function processTranslation(
key: string,
translation: string,
entries: Map<string, WordingEntry>,
configuration: GeneratorConfiguration
): WordingEntry {
const { detectPlurial, detectInterpolation } = configuration;

const interpolationsNames = findInterpolations(translation);
const interpolations = detectInterpolation
? new Map<string, InterpolationType>(
interpolationsNames.map((name) => [name, "string"])
)
: new Map();

if (detectPlurial && isEnumerable(key)) {
const shrunkKey = removeLastPart(key);
interpolations.set("count", "number");

if (entries.has(shrunkKey)) {
// If the entry already exists, merge interpolations
const existingEntry = entries.get(shrunkKey)!;
return {
key: shrunkKey,
interpolations: mergeInterpolations(
existingEntry.interpolations,
interpolations
),
};
} else {
return { key: shrunkKey, interpolations };
}
}
return { key, interpolations };
}

const removeLastPart = (key: WordingKey, delimiter = ".") =>
key.split(delimiter).slice(0, -1).join(delimiter);

function mergeInterpolations(
existingInterpolations: Map<string, InterpolationType>,
newInterpolations: Map<string, InterpolationType>
): Map<string, InterpolationType> {
const mergedInterpolations = new Map([...existingInterpolations]);

newInterpolations.forEach((type, name) => {
if (!mergedInterpolations.has(name)) {
mergedInterpolations.set(name, type);
}
});

return mergedInterpolations;
}
7 changes: 7 additions & 0 deletions src/translation/is-enumerable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Support i18n-js plurialize format
* Support only Zero, One and Other plural form
* Translations keys ends with .one, .zero and .other
*/
export const isEnumerable = (key: string) =>
[".one", ".zero", ".other"].some((enumeration) => key.endsWith(enumeration));
35 changes: 35 additions & 0 deletions src/wording/flatten.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { flatten } from "./flatten";

describe("flatten function", () => {
it("should flatten a nested object", () => {
const nestedObject = {
greeting: {
en: "Hello",
fr: "Bonjour",
nested: {
world: {
en: "World",
fr: "Monde",
},
},
},
goodbye: {
en: "Goodbye",
fr: "Au revoir",
},
};

const expectedFlattenedObject = {
"greeting.en": "Hello",
"greeting.fr": "Bonjour",
"greeting.nested.world.en": "World",
"greeting.nested.world.fr": "Monde",
"goodbye.en": "Goodbye",
"goodbye.fr": "Au revoir",
};

const result = flatten(nestedObject);

expect(result).toEqual(expectedFlattenedObject);
});
});
23 changes: 23 additions & 0 deletions src/wording/flatten.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
type NestedObject = {
[key: string]: string | NestedObject;
};

export function flatten(
object: NestedObject,
parentKey = ""
): Record<string, string> {
const flattenedObject: Record<string, string> = {};

for (const [key, value] of Object.entries(object)) {
const currentKey = parentKey ? `${parentKey}.${key}` : key;

if (typeof value === "object") {
// Recursively flatten nested keys
Object.assign(flattenedObject, flatten(value, currentKey));
} else {
flattenedObject[currentKey] = value;
}
}

return flattenedObject;
}
25 changes: 25 additions & 0 deletions src/wording/wording-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as fs from "fs";
import { Configuration } from "../config/config-loader";
import { flatten } from "./flatten";

export function loadWordings(configuration: Configuration) {
const wordings = readWordingFile(configuration.input.path);

if (configuration.input.format === "nested") {
return flatten(wordings);
}

return wordings;
}

function readWordingFile(wordingPath: string) {
try {
const wordings = fs.readFileSync(wordingPath, "utf-8");
return JSON.parse(wordings);
} catch (error) {
console.error(
`Error reading or parsing the wording file: ${(error as Error).message}`
);
process.exit(1);
}
}
11 changes: 11 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2015",
"module": "CommonJS",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

0 comments on commit 632ee62

Please sign in to comment.