From aa88fd340c1b1e6baf9c749adbf14a46d4e65589 Mon Sep 17 00:00:00 2001 From: Fabien Drault Date: Mon, 5 Aug 2024 15:22:22 +0200 Subject: [PATCH] feat(config): Generator is open to configuration --- package.json | 13 +- src/command/init.ts | 17 ++ src/config/config-loader.ts | 6 + src/rules/key.ts | 37 ++++ src/rules/transformer.ts | 53 +++++ src/rules/translation.ts | 54 +++++ src/templates/generate-type.ts | 2 +- src/templates/template-type.ts | 9 +- .../generate-template-data.test.ts | 60 +++++- src/translation/generate-template-data.ts | 191 +++++++++--------- 10 files changed, 337 insertions(+), 105 deletions(-) create mode 100644 src/rules/key.ts create mode 100644 src/rules/transformer.ts create mode 100644 src/rules/translation.ts diff --git a/package.json b/package.json index 340a86b..7b52df6 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,16 @@ "files": [ "dist/", "bin/" - ] + ], + "prettier": { + "arrowParens": "always", + "bracketSpacing": true, + "bracketSameLine": true, + "printWidth": 100, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + } } diff --git a/src/command/init.ts b/src/command/init.ts index 88623a6..5143ab5 100644 --- a/src/command/init.ts +++ b/src/command/init.ts @@ -11,6 +11,23 @@ export function init() { output: { path: "./i18n/translations.d.ts", }, + rules: [ + { + "//": "Add pluralization placeholders", + condition: { keyEndsWith: ["zero", "one", "other"] }, + transformer: { + addPlaceholder: { name: "count", type: ["number"] }, + removeLastPart: true, + }, + }, + { + "//": "Add interpolation values for matched placeholders", + condition: { placeholderPattern: { prefix: "{{", suffix: "}}" } }, + transformer: { + addMatchedPlaceholder: { type: ["string", "number"] }, + }, + }, + ], }; console.log(`Initialize config file ${configurationFilename}\n`); writeDefaultConfiguration(defaultConfiguration, configurationFilename); diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts index 453178a..3e23164 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -1,4 +1,6 @@ import * as fs from "fs"; +import { KeyEndsWithRule } from "../rules/key"; +import { TranslationMatchRule } from "../rules/translation"; export function readConfigFile(configPath: string): Configuration { try { @@ -15,6 +17,9 @@ Run "npm run i18n-typegen init" to generate one\n' type InputFormat = "flatten" | "nested"; +export type Rule = (TranslationMatchRule | KeyEndsWithRule) & { + "//"?: string; +}; export interface Configuration { input: { format: InputFormat; @@ -23,4 +28,5 @@ export interface Configuration { output: { path: string; }; + rules: Rule[]; } diff --git a/src/rules/key.ts b/src/rules/key.ts new file mode 100644 index 0000000..1946df0 --- /dev/null +++ b/src/rules/key.ts @@ -0,0 +1,37 @@ +import { Rule } from "../config/config-loader"; +import { TranslationEntry } from "../translation/generate-template-data"; +import { + AddPlaceholderTransformer, + RemoveKeyPartTransformer, + applyAddPlaceholderTransformer, + applyRemoveLastPartTransformer, +} from "./transformer"; + +type KeyEndsWithCondition = { keyEndsWith: string[] }; + +export type KeyEndsWithRule = { + condition: KeyEndsWithCondition; + transformer: AddPlaceholderTransformer | RemoveKeyPartTransformer; +}; + +export function isKeyRule(rule: Rule): rule is KeyEndsWithRule { + return "keyEndsWith" in rule.condition; +} + +export function applyKeyEndsWithRule( + rule: KeyEndsWithRule, + entry: TranslationEntry +): TranslationEntry { + const { key } = entry; + let result = entry; + const match = rule.condition.keyEndsWith.some((endingPart) => key.endsWith(`.${endingPart}`)); + if (match) { + if ("removeLastPart" in rule.transformer) { + result = applyRemoveLastPartTransformer(rule.transformer, result); + } + if ("addPlaceholder" in rule.transformer) { + result = applyAddPlaceholderTransformer(rule.transformer, result); + } + } + return result; +} diff --git a/src/rules/transformer.ts b/src/rules/transformer.ts new file mode 100644 index 0000000..1bec5dc --- /dev/null +++ b/src/rules/transformer.ts @@ -0,0 +1,53 @@ +import { TranslationEntry } from "../translation/generate-template-data"; + +export type AddPlaceholderTransformer = { + addPlaceholder: { + name: string; + type: string[]; + }; +}; +export type RemoveKeyPartTransformer = { removeLastPart: true }; +export type AddMatchedPlaceholderTransformer = { + addMatchedPlaceholder: { + type: string[]; + }; +}; + +export function applyAddPlaceholderTransformer( + transformer: AddPlaceholderTransformer, + entry: TranslationEntry +): TranslationEntry { + const previousType = + entry.interpolations.get(transformer.addPlaceholder.name) ?? []; + entry.interpolations.set( + transformer.addPlaceholder.name, + previousType.concat(transformer.addPlaceholder.type) + ); + return entry; +} + +export function applyAddMatchedPlaceholderTransformer( + transformer: AddMatchedPlaceholderTransformer, + placeholder: string, + entry: TranslationEntry +): TranslationEntry { + const previousType = entry.interpolations.get(placeholder) ?? []; + entry.interpolations.set( + placeholder, + previousType.concat(transformer.addMatchedPlaceholder.type) + ); + return entry; +} + +export function applyRemoveLastPartTransformer( + transformer: RemoveKeyPartTransformer, + entry: TranslationEntry +): TranslationEntry { + if (transformer.removeLastPart) { + return { ...entry, key: removeLastPart(entry.key) }; + } + return entry; +} + +const removeLastPart = (key: string, delimiter = ".") => + key.split(delimiter).slice(0, -1).join(delimiter); diff --git a/src/rules/translation.ts b/src/rules/translation.ts new file mode 100644 index 0000000..102eb7a --- /dev/null +++ b/src/rules/translation.ts @@ -0,0 +1,54 @@ +import { Rule } from "../config/config-loader"; +import { TranslationEntry } from "../translation/generate-template-data"; +import { + AddMatchedPlaceholderTransformer, + AddPlaceholderTransformer, + applyAddMatchedPlaceholderTransformer, + applyAddPlaceholderTransformer, +} from "./transformer"; + +type TranslationMatchCondition = { + placeholderPattern: { + prefix: string; + suffix: string; + }; +}; + +export type TranslationMatchRule = { + condition: TranslationMatchCondition; + transformer: AddMatchedPlaceholderTransformer | AddPlaceholderTransformer; +}; + +export function isTranslationRule(rule: Rule): rule is TranslationMatchRule { + return "placeholderPattern" in rule.condition; +} + +export function applyTranslationMatchRule( + rule: TranslationMatchRule, + entry: TranslationEntry +): TranslationEntry { + let result = entry; + const { prefix, suffix } = rule.condition.placeholderPattern; + const regexp = new RegExp(`${prefix}(.*?)${suffix}`, "g"); + + const matches: string[] = []; + let match; + while ((match = regexp.exec(entry.translation)) !== null) { + matches.push(match[1]); + } + + if ("addMatchedPlaceholder" in rule.transformer) { + const transformer = rule.transformer; + matches.forEach((placeholder) => { + result = applyAddMatchedPlaceholderTransformer( + transformer, + placeholder, + result + ); + }); + } + if ("addPlaceholder" in rule.transformer) { + result = applyAddPlaceholderTransformer(rule.transformer, result); + } + return result; +} diff --git a/src/templates/generate-type.ts b/src/templates/generate-type.ts index fde7fe2..ba58d4b 100644 --- a/src/templates/generate-type.ts +++ b/src/templates/generate-type.ts @@ -36,7 +36,7 @@ export function generateType(configuration: Configuration) { const template = openTemplate(); const wordings = loadWordings(configuration); - const templateData = generateTemplateData(wordings); + const templateData = generateTemplateData(wordings, configuration); const generatedType = Mustache.render(template, { keys: templateData, diff --git a/src/templates/template-type.ts b/src/templates/template-type.ts index 7723cbe..a23fab1 100644 --- a/src/templates/template-type.ts +++ b/src/templates/template-type.ts @@ -1,10 +1,15 @@ export interface InterpolationTemplateData { name: string; - type: "string" | "number"; + type: InterpolationTypeTemplateData[]; last?: boolean; } -export interface WordingEntryTemplateData { +export interface InterpolationTypeTemplateData { + value: string; + last?: boolean; +} + +export interface TranslationEntryTemplateData { key: string; interpolations: InterpolationTemplateData[]; } diff --git a/src/translation/generate-template-data.test.ts b/src/translation/generate-template-data.test.ts index 7380643..75a0846 100644 --- a/src/translation/generate-template-data.test.ts +++ b/src/translation/generate-template-data.test.ts @@ -1,5 +1,6 @@ import { generateTemplateData } from "./generate-template-data"; +const mockConfig = { input: {} as any, output: {} as any, rules: [] }; describe("generateType", () => { it("should handle pluralization correctly", () => { const translations = { @@ -8,13 +9,24 @@ describe("generateType", () => { "day.zero": "0 day", }; - const result = generateTemplateData(translations, { detectPlurial: true }); + const result = generateTemplateData(translations, { + ...mockConfig, + rules: [ + { + condition: { keyEndsWith: ["one", "other", "zero"] }, + transformer: { + addPlaceholder: { name: "count", type: ["number"] }, + removeLastPart: true, + }, + }, + ], + }); expect(result).toHaveLength(1); expect(result[0]).toEqual({ key: "day", - interpolations: [{ name: "count", type: "number", last: true }], + interpolations: [{ name: "count", type: [{ value: "number", last: true }], last: true }], }); }); @@ -25,7 +37,7 @@ describe("generateType", () => { "day.zero": "0 day", }; - const result = generateTemplateData(translations, { detectPlurial: false }); + const result = generateTemplateData(translations, mockConfig); expect(result).toHaveLength(3); @@ -35,7 +47,7 @@ describe("generateType", () => { }); expect(result[1]).toEqual({ key: "day.other", - interpolations: [{ name: "count", type: "string", last: true }], + interpolations: [], }); }); @@ -44,15 +56,30 @@ describe("generateType", () => { greeting: "Hello {{firstName}} {{familyName}}", }; - const result = generateTemplateData(translations); + const result = generateTemplateData(translations, { + ...mockConfig, + rules: [ + { + condition: { placeholderPattern: { prefix: "{{", suffix: "}}" } }, + transformer: { addMatchedPlaceholder: { type: ["string", "number"] } }, + }, + ], + }); expect(result).toHaveLength(1); expect(result[0]).toEqual({ key: "greeting", interpolations: [ - { name: "firstName", type: "string" }, - { name: "familyName", type: "string", last: true }, + { + name: "firstName", + type: [{ value: "string" }, { value: "number", last: true }], + }, + { + name: "familyName", + type: [{ value: "string" }, { value: "number", last: true }], + last: true, + }, ], }); }); @@ -64,16 +91,27 @@ describe("generateType", () => { "day.zero": "0 {{mood}} day", }; - const result = generateTemplateData(translations); + const rules = [ + { + condition: { placeholderPattern: { prefix: "{{", suffix: "}}" } }, + transformer: { addMatchedPlaceholder: { type: ["string"] } }, + }, + { + condition: { keyEndsWith: ["one", "other", "zero"] }, + transformer: { addPlaceholder: { name: "count", type: ["number"] }, removeLastPart: true }, + }, + ]; + + const result = generateTemplateData(translations, { ...mockConfig, rules }); 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 }, + { name: "count", type: [{ value: "number" }, { value: "string", last: true }] }, + { name: "mood", type: [{ value: "string", last: true }] }, + { name: "moods", type: [{ value: "string", last: true }], last: true }, ], }); }); diff --git a/src/translation/generate-template-data.ts b/src/translation/generate-template-data.ts index b64d27b..a0540bf 100644 --- a/src/translation/generate-template-data.ts +++ b/src/translation/generate-template-data.ts @@ -1,116 +1,127 @@ +import { Configuration } from "../config/config-loader"; +import { applyKeyEndsWithRule, isKeyRule } from "../rules/key"; +import { applyTranslationMatchRule, isTranslationRule } from "../rules/translation"; import { - InterpolationTemplateData, - WordingEntryTemplateData, + InterpolationTypeTemplateData, + TranslationEntryTemplateData, } from "../templates/template-type"; -import { findInterpolations } from "./find-interpolation"; -import { isEnumerable } from "./is-enumerable"; -type WordingKey = string; +type TranslationKey = string; type Translation = string; -interface WordingEntry { - key: WordingKey; - interpolations: Map; +export interface TranslationEntry { + key: TranslationKey; + translation: string; + interpolations: Map; } -interface GeneratorConfiguration { - detectPlurial: boolean; - detectInterpolation: boolean; -} - -type InterpolationType = "string" | "number"; - -const defaultConfiguration = { - detectPlurial: true, - detectInterpolation: true, -}; +export type InterpolationType = string; +function createEntry(item: { key: string; translation: string }): TranslationEntry { + return { + key: item.key, + translation: item.translation, + interpolations: new Map(), + }; +} export function generateTemplateData( - translations: Record, - overwriteConfiguration: Partial = {} -): WordingEntryTemplateData[] { - const config = { ...defaultConfiguration, ...overwriteConfiguration }; - const entries: Map = new Map(); + translations: Record, + configuration: Configuration +): TranslationEntryTemplateData[] { + const entries: TranslationEntry[] = []; - (Object.entries(translations) as [WordingKey, Translation][]).forEach( + (Object.entries(translations) as [TranslationKey, Translation][]).forEach( ([key, translation]) => { - const entry = processTranslation(key, translation, entries, config); - entries.set(entry.key, entry); + const entry = processTranslation(createEntry({ key, translation }), configuration); + entries.push(entry); } ); - return mapToTemplateData(entries); + return toTemplateData(entries); } -function mapToTemplateData( - entries: Map -): WordingEntryTemplateData[] { - return Array.from(entries.values()).map((it) => ({ - key: it.key, - interpolations: interpolationsMapToTemplate(it.interpolations), - })); -} +function toTemplateData(entries: TranslationEntry[]): TranslationEntryTemplateData[] { + const entryMap = new Map(); + + for (const entry of entries) { + // Retrieve or initialize the interpolations for the current entry key + let interpolations = entryMap.get(entry.key)?.interpolations ?? []; + + // Add new interpolations from the entry + for (const [name, types] of entry.interpolations) { + const existingInterpolation = interpolations.find((interpol) => interpol.name === name); + + if (existingInterpolation) { + // Merge types if the interpolation already exists + existingInterpolation.type = mergeUniqueTypes(existingInterpolation.type, types); + } else { + // Add new interpolation + interpolations.push({ + name, + type: types.map((t) => ({ value: t })), + }); + } + } -function interpolationsMapToTemplate( - interpolations: Map -) { - const interpolationArray: InterpolationTemplateData[] = Array.from( - interpolations.entries() - ).map(([name, type]) => ({ name, type })); - if (interpolationArray.length > 0) - interpolationArray[interpolationArray.length - 1].last = true; - return interpolationArray; -} + // Update the entry map + entryMap.set(entry.key, { key: entry.key, interpolations }); + } -function processTranslation( - key: string, - translation: string, - entries: Map, - configuration: GeneratorConfiguration -): WordingEntry { - const { detectPlurial, detectInterpolation } = configuration; - - const interpolationsNames = findInterpolations(translation); - const interpolations = detectInterpolation - ? new Map( - 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 }; + // Set the `last` property + for (const entry of entryMap.values()) { + const lastInterpolationIndex = entry.interpolations.length - 1; + if (lastInterpolationIndex >= 0) { + entry.interpolations[lastInterpolationIndex].last = true; + } + + for (const interpolation of entry.interpolations) { + const lastTypeIndex = interpolation.type.length - 1; + if (lastTypeIndex >= 0) { + interpolation.type[lastTypeIndex].last = true; + } } } - return { key, interpolations }; -} -const removeLastPart = (key: WordingKey, delimiter = ".") => - key.split(delimiter).slice(0, -1).join(delimiter); + return Array.from(entryMap.values()); +} -function mergeInterpolations( - existingInterpolations: Map, - newInterpolations: Map -): Map { - const mergedInterpolations = new Map([...existingInterpolations]); +function mergeUniqueTypes( + existingTypes: InterpolationTypeTemplateData[], + newTypes: string[] +): InterpolationTypeTemplateData[] { + const existingTypeValues = new Set(existingTypes.map((t) => t.value)); + for (const newType of newTypes) { + if (!existingTypeValues.has(newType)) { + existingTypes.push({ value: newType }); + } + } + return existingTypes; +} - newInterpolations.forEach((type, name) => { - if (!mergedInterpolations.has(name)) { - mergedInterpolations.set(name, type); +function processTranslation( + entry: TranslationEntry, + configuration: Configuration +): TranslationEntry { + let result = entry; + + // Apply key rules first + const sortedRules = configuration.rules.sort((a, b) => { + if (isKeyRule(a) && !isKeyRule(b)) { + return -1; } + if (isKeyRule(b) && !isKeyRule(a)) { + return 1; + } + return 0; }); - return mergedInterpolations; + for (const rule of sortedRules) { + if (isKeyRule(rule)) { + result = applyKeyEndsWithRule(rule, result); + } + if (isTranslationRule(rule)) { + result = applyTranslationMatchRule(rule, result); + } + } + + return result; }