From a59e63d9f679b372111dbf1f9f7b3765ad71f282 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 8 Jun 2026 18:16:26 +0200 Subject: [PATCH 1/6] feat: add i18next-type-generator and migrate i18n to i18next v25 Port archid-components#273 to @wolfstar scope: new CLI for i18next type augmentations, remove LanguageKeys/T/FT helpers, and adopt namespace-aware TFunction typing across http-framework-i18n and shared-http-pieces. Co-authored-by: Cursor --- .npm-deprecaterc.yml | 1 + README.md | 1 + packages/http-framework-i18n/README.md | 23 +++-- packages/http-framework-i18n/package.json | 2 +- packages/http-framework-i18n/src/index.ts | 17 ---- .../http-framework-i18n/src/lib/functions.ts | 10 --- .../http-framework-i18n/src/lib/registry.ts | 8 +- packages/http-framework-i18n/src/lib/types.ts | 21 ----- packages/http-framework-i18n/src/lib/utils.ts | 64 +++----------- packages/i18next-type-generator/CHANGELOG.md | 5 ++ packages/i18next-type-generator/README.md | 45 ++++++++++ packages/i18next-type-generator/package.json | 66 +++++++++++++++ packages/i18next-type-generator/src/cli.ts | 29 +++++++ .../i18next-type-generator/src/generate.ts | 84 +++++++++++++++++++ .../i18next-type-generator/tsdown.config.ts | 13 +++ packages/shared-http-pieces/package.json | 2 + .../src/@types/i18next-custom.d.ts | 11 +++ .../src/@types/i18next.d.ts | 21 +++++ .../shared-http-pieces/src/commands/info.ts | 46 +++++----- packages/shared-http-pieces/src/index.ts | 1 - .../src/lib/i18n/LanguageKeys.ts | 1 - .../src/lib/i18n/LanguageKeys/All.ts | 1 - .../src/lib/i18n/LanguageKeys/Commands/All.ts | 1 - .../lib/i18n/LanguageKeys/Commands/Shared.ts | 16 ---- pnpm-lock.yaml | 57 +++++++++++-- scripts/clean.mjs | 2 + 26 files changed, 384 insertions(+), 164 deletions(-) delete mode 100644 packages/http-framework-i18n/src/lib/functions.ts delete mode 100644 packages/http-framework-i18n/src/lib/types.ts create mode 100644 packages/i18next-type-generator/CHANGELOG.md create mode 100644 packages/i18next-type-generator/README.md create mode 100644 packages/i18next-type-generator/package.json create mode 100644 packages/i18next-type-generator/src/cli.ts create mode 100644 packages/i18next-type-generator/src/generate.ts create mode 100644 packages/i18next-type-generator/tsdown.config.ts create mode 100644 packages/shared-http-pieces/src/@types/i18next-custom.d.ts create mode 100644 packages/shared-http-pieces/src/@types/i18next.d.ts delete mode 100644 packages/shared-http-pieces/src/lib/i18n/LanguageKeys.ts delete mode 100644 packages/shared-http-pieces/src/lib/i18n/LanguageKeys/All.ts delete mode 100644 packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/All.ts delete mode 100644 packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/Shared.ts diff --git a/.npm-deprecaterc.yml b/.npm-deprecaterc.yml index 23bc536..825870c 100644 --- a/.npm-deprecaterc.yml +++ b/.npm-deprecaterc.yml @@ -4,6 +4,7 @@ package: - '@wolfstar/http-framework' - '@wolfstar/http-framework-i18n' - '@wolfstar/i18next-backend' + - '@wolfstar/i18next-type-generator' - '@wolfstar/influx-utilities' - '@wolfstar/logger' - '@wolfstar/reddit-helpers' diff --git a/README.md b/README.md index fb125f5..bd453d4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ [![npm](https://img.shields.io/npm/v/@wolfstar/http-framework?color=crimson&logo=npm&style=flat-square&label=@wolfstar/http-framework)](https://www.npmjs.com/package/@wolfstar/http-framework) [![npm](https://img.shields.io/npm/v/@wolfstar/http-framework-i18n?color=crimson&logo=npm&style=flat-square&label=@wolfstar/http-framework-i18n)](https://www.npmjs.com/package/@wolfstar/http-framework-i18n) [![npm](https://img.shields.io/npm/v/@wolfstar/i18next-backend?color=crimson&logo=npm&style=flat-square&label=@wolfstar/i18next-backend)](https://www.npmjs.com/package/@wolfstar/i18next-backend) +[![npm](https://img.shields.io/npm/v/@wolfstar/i18next-type-generator?color=crimson&logo=npm&style=flat-square&label=@wolfstar/i18next-type-generator)](https://www.npmjs.com/package/@wolfstar/i18next-type-generator) [![npm](https://img.shields.io/npm/v/@wolfstar/influx-utilities?color=crimson&logo=npm&style=flat-square&label=@wolfstar/influx-utilities)](https://www.npmjs.com/package/@wolfstar/influx-utilities) [![npm](https://img.shields.io/npm/v/@wolfstar/logger?color=crimson&logo=npm&style=flat-square&label=@wolfstar/logger)](https://www.npmjs.com/package/@wolfstar/logger) [![npm](https://img.shields.io/npm/v/@wolfstar/reddit-helpers?color=crimson&logo=npm&style=flat-square&label=@wolfstar/reddit-helpers)](https://www.npmjs.com/package/@wolfstar/reddit-helpers) diff --git a/packages/http-framework-i18n/README.md b/packages/http-framework-i18n/README.md index 3fca8ef..0dec440 100644 --- a/packages/http-framework-i18n/README.md +++ b/packages/http-framework-i18n/README.md @@ -34,19 +34,26 @@ addFormatters( await init(); ``` +> **Note**: If you want to customize the options, please check [i18next's TypeScript guide](https://www.i18next.com/overview/typescript) to improve the experience. + ### Definition -```typescript -import { T, FT } from '@wolfstar/http-framework-i18n'; +Generate type augmentations with [`@wolfstar/i18next-type-generator`](https://www.npmjs.com/package/@wolfstar/i18next-type-generator): -export const InvalidInput = T('path/to/file:invalidInput'); -export const AddResult = FT<{ left: number; right: number; result: number }>('path/to/file:addResult'); +```bash +i18next-type-generator ./src/locales/en-US/ ./src/@types/i18next.d.ts ``` ### Consumption ```typescript -import { getSupportedLanguageName, getSupportedUserLanguageName, getT, resolveKey, resolveUserKey } from '@wolfstar/http-framework-i18n'; +import { + getSupportedLanguageName, + getSupportedUserLanguageName, + getT, + getSupportedLanguageT, + getSupportedUserLanguageT +} from '@wolfstar/http-framework-i18n'; // Get the name of the supported guild language, falling back to the user's on DMs: const guildLanguage = getSupportedLanguageName(interaction); @@ -55,11 +62,11 @@ const guildLanguage = getSupportedLanguageName(interaction); const userLanguage = getSupportedUserLanguageName(interaction); // Get the function to get a translated key: -const t = getT(guildLanguage); +const t = getT(guildLanguage, 'commands/shared'); // Resolving a given key, this calls `getT` and `getSupportedLanguageName` under the hood: -const content = resolveKey(interaction, InvalidInput); +const content = getSupportedLanguageT(interaction, 'commands/shared')('invalidInput'); // Resolving a given key, this calls `getT` and `getSupportedUserLanguageName` under the hood: -const content = resolveUserKey(interaction, AddResult, { left: 5, right: 10, result: 15 }); +const content = getSupportedUserLanguageT(interaction, 'commands/shared')('addResult', { left: 5, right: 10, result: 15 }); ``` diff --git a/packages/http-framework-i18n/package.json b/packages/http-framework-i18n/package.json index b608d04..d271978 100644 --- a/packages/http-framework-i18n/package.json +++ b/packages/http-framework-i18n/package.json @@ -50,7 +50,7 @@ "@sapphire/utilities": "^3.18.2", "@wolfstar/i18next-backend": "workspace:^", "discord-api-types": "^0.38.8", - "i18next": "^22.5.1", + "i18next": "^25.10.10", "tslib": "^2.8.1" }, "devDependencies": { diff --git a/packages/http-framework-i18n/src/index.ts b/packages/http-framework-i18n/src/index.ts index 6eb1321..38dab66 100644 --- a/packages/http-framework-i18n/src/index.ts +++ b/packages/http-framework-i18n/src/index.ts @@ -1,20 +1,3 @@ export { default as i18next, type InitOptions, type TFunction, type TOptions, type TOptionsBase } from 'i18next'; -export * from './lib/functions.js'; export * from './lib/registry.js'; -export type * from './lib/types.js'; export * from './lib/utils.js'; - -import type { NonNullObject } from '@sapphire/utilities'; -import type { TypedFT, TypedT } from './lib/types.js'; - -declare module 'i18next' { - export interface TFunction { - lng: string; - ns?: string; - - (key: TypedT, options?: TOptionsBase | string): TReturn; - (key: TypedT, defaultValue: TReturn, options?: TOptionsBase | string): TReturn; - (key: TypedFT, options?: TOptions): TReturn; - (key: TypedFT, defaultValue: TReturn, options?: TOptions): TReturn; - } -} diff --git a/packages/http-framework-i18n/src/lib/functions.ts b/packages/http-framework-i18n/src/lib/functions.ts deleted file mode 100644 index aaf77e7..0000000 --- a/packages/http-framework-i18n/src/lib/functions.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { NonNullObject } from '@sapphire/utilities'; -import type { TypedFT, TypedT } from './types.js'; - -export function T(k: string): TypedT { - return k as TypedT; -} - -export function FT(k: string): TypedFT { - return k as TypedFT; -} diff --git a/packages/http-framework-i18n/src/lib/registry.ts b/packages/http-framework-i18n/src/lib/registry.ts index 4b580b9..9f2a0fc 100644 --- a/packages/http-framework-i18n/src/lib/registry.ts +++ b/packages/http-framework-i18n/src/lib/registry.ts @@ -1,7 +1,6 @@ -import { Collection } from '@discordjs/collection'; import { Backend } from '@wolfstar/i18next-backend'; import { Locale, type LocaleString } from 'discord-api-types/v10'; -import i18next, { getFixedT, type InitOptions, type TFunction } from 'i18next'; +import i18next, { getFixedT, type DefaultNamespace, type InitOptions, type Namespace, type TFunction } from 'i18next'; import type { PathLike } from 'node:fs'; import { opendir } from 'node:fs/promises'; import { join } from 'node:path'; @@ -85,8 +84,7 @@ async function loadLocale(directory: string, ns: string) { } } -const fixedCache = new Collection(); -export function getT(locale: LocaleString): TFunction<'translation', undefined, 'translation'> { +export function getT(locale: LocaleString, namespace?: Ns): TFunction { if (!loadedLocales.has(locale)) throw new ReferenceError(`Invalid language (${locale})`); - return fixedCache.ensure(locale, () => getFixedT(locale)); + return getFixedT(locale, namespace); } diff --git a/packages/http-framework-i18n/src/lib/types.ts b/packages/http-framework-i18n/src/lib/types.ts deleted file mode 100644 index a02e727..0000000 --- a/packages/http-framework-i18n/src/lib/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { NonNullObject } from '@sapphire/utilities'; - -export type TypedT = string & { __type__: TCustom }; - -export type TypedFT = string & { __args__: TArgs; __return__: TReturn }; - -export interface Value { - value: T; -} - -export interface Values { - values: readonly T[]; - count: number; -} - -export interface Difference { - previous: T; - next: T; -} - -export type LocalePrefixKey = `commands/${string}:${string}`; diff --git a/packages/http-framework-i18n/src/lib/utils.ts b/packages/http-framework-i18n/src/lib/utils.ts index dde30df..6a2d0e8 100644 --- a/packages/http-framework-i18n/src/lib/utils.ts +++ b/packages/http-framework-i18n/src/lib/utils.ts @@ -2,11 +2,12 @@ import { Collection } from '@discordjs/collection'; import type { NonNullObject } from '@sapphire/utilities'; import { lazy } from '@sapphire/utilities'; import type { APIInteraction, APIPingInteraction, LocaleString, LocalizationMap } from 'discord-api-types/v10'; -import type { TFunction, TOptions, TOptionsBase } from 'i18next'; +import type { DefaultNamespace, Namespace, TFunction, TypeOptions } from 'i18next'; import { getT, loadedLocales } from './registry.js'; -import type { LocalePrefixKey, TypedFT, TypedT } from './types.js'; export type Interaction = Pick, 'locale' | 'guild_locale' | 'guild_id'>; +export type LocaleSeparator = TypeOptions['nsSeparator']; +export type LocalePrefixKey = `${string}${LocaleSeparator}${string}`; export function getSupportedUserLanguageName(interaction: Interaction): LocaleString { if (loadedLocales.has(interaction.locale)) return interaction.locale; @@ -14,8 +15,8 @@ export function getSupportedUserLanguageName(interaction: Interaction): LocaleSt return 'en-US'; } -export function getSupportedUserLanguageT(interaction: Interaction): TFunction { - return getT(getSupportedUserLanguageName(interaction)); +export function getSupportedUserLanguageT(interaction: Interaction, namespace?: Ns): TFunction { + return getT(getSupportedUserLanguageName(interaction), namespace); } export function getSupportedLanguageName(interaction: Interaction): LocaleString { @@ -27,47 +28,8 @@ export function getSupportedLanguageName(interaction: Interaction): LocaleString return 'en-US'; } -export function getSupportedLanguageT(interaction: Interaction): TFunction { - return getT(getSupportedLanguageName(interaction)); -} - -export function resolveUserKey(interaction: Interaction, key: TypedT, options?: TOptionsBase | string): TReturn; -export function resolveUserKey( - interaction: Interaction, - key: TypedT, - defaultValue: TReturn, - options?: TOptionsBase | string -): TReturn; -export function resolveUserKey( - interaction: Interaction, - key: TypedFT, - options?: TOptions -): TReturn; -export function resolveUserKey( - interaction: Interaction, - key: TypedFT, - defaultValue: TReturn, - options?: TOptions -): TReturn; -export function resolveUserKey(interaction: Interaction, ...args: [any, any, any?]) { - return getSupportedUserLanguageT(interaction)(...args); -} - -export function resolveKey(interaction: Interaction, key: TypedT, options?: TOptionsBase | string): TReturn; -export function resolveKey(interaction: Interaction, key: TypedT, defaultValue: TReturn, options?: TOptionsBase | string): TReturn; -export function resolveKey( - interaction: Interaction, - key: TypedFT, - options?: TOptions -): TReturn; -export function resolveKey( - interaction: Interaction, - key: TypedFT, - defaultValue: TReturn, - options?: TOptions -): TReturn; -export function resolveKey(interaction: Interaction, ...args: [any, any, any?]) { - return getSupportedLanguageT(interaction)(...args); +export function getSupportedLanguageT(interaction: Interaction, namespace?: Ns): TFunction { + return getT(getSupportedLanguageName(interaction), namespace); } const getLocales = lazy(() => new Collection([...loadedLocales].map((locale) => [locale, getT(locale)]))); @@ -83,7 +45,7 @@ const getDefaultT = lazy(() => { * @returns The retrieved data. * @remarks This should be called **strictly** after loading the locales. */ -export function getLocalizedData(key: TypedT): LocalizedData { +export function getLocalizedData(key: LocalePrefixKey): LocalizedData { const locales = getLocales(); const defaultT = getDefaultT(); @@ -99,7 +61,7 @@ export function getLocalizedData(key: TypedT): LocalizedData { * @param key The key to get the localizations from. * @returns The updated builder. */ -export function applyNameLocalizedBuilder(builder: T, key: TypedT) { +export function applyNameLocalizedBuilder(builder: T, key: LocalePrefixKey) { const result = getLocalizedData(key); return builder.setName(result.value).setNameLocalizations(result.localizations); } @@ -110,7 +72,7 @@ export function applyNameLocalizedBuilder(builder: T, * @param key The key to get the localizations from. * @returns The updated builder. */ -export function applyDescriptionLocalizedBuilder(builder: T, key: TypedT) { +export function applyDescriptionLocalizedBuilder(builder: T, key: LocalePrefixKey) { const result = getLocalizedData(key); return builder.setDescription(result.value).setDescriptionLocalizations(result.localizations); } @@ -126,16 +88,16 @@ export function applyDescriptionLocalizedBuilder( builder: T, - ...params: [root: LocalePrefixKey] | [name: TypedT, description: TypedT] + ...params: [root: LocalePrefixKey] | [name: LocalePrefixKey, description: LocalePrefixKey] ): T { - const [localeName, localeDescription] = params.length === 1 ? [`${params[0]}Name` as TypedT, `${params[0]}Description` as TypedT] : params; + const [localeName, localeDescription] = params.length === 1 ? [`${params[0]}Name` as const, `${params[0]}Description` as const] : params; applyNameLocalizedBuilder(builder, localeName); applyDescriptionLocalizedBuilder(builder, localeDescription); return builder; } -export function createSelectMenuChoiceName(key: TypedT, value?: V): createSelectMenuChoiceName.Result { +export function createSelectMenuChoiceName(key: LocalePrefixKey, value?: V): createSelectMenuChoiceName.Result { const result = getLocalizedData(key); return { ...value, diff --git a/packages/i18next-type-generator/CHANGELOG.md b/packages/i18next-type-generator/CHANGELOG.md new file mode 100644 index 0000000..498646a --- /dev/null +++ b/packages/i18next-type-generator/CHANGELOG.md @@ -0,0 +1,5 @@ +# @wolfstar/i18next-type-generator + +## 3.0.1 + +Initial release under the `@wolfstar` scope. diff --git a/packages/i18next-type-generator/README.md b/packages/i18next-type-generator/README.md new file mode 100644 index 0000000..7e9dbf9 --- /dev/null +++ b/packages/i18next-type-generator/README.md @@ -0,0 +1,45 @@ +# `@wolfstar/i18next-type-generator` + +A fast and modern type augmentation generator for the [`@wolfstar/i18next-backend`](https://www.npmjs.com/package/@wolfstar/i18next-backend) filesystem-based [`i18next`](https://www.npmjs.com/package/i18next) backend for Node.js. + +## Usage + +```bash +$ i18next-type-generator [options] [source] [destination] + +# Arguments: +# source The directory to generate types from (default: "./src/locales/en-US/") +# destination The directory to generate types to (default: "./src/@types/i18next.d.ts") +# +# Options: +# -V, --version output the version number +# -v, --verbose Verbose output +# --no-prettier Disable prettier +# -h, --help display help for command +``` + +This CLI tool generates a `.d.ts` file with the following structure: + +```typescript +// This file is automatically generated, do not edit it. +import 'i18next'; + +declare module 'i18next' { + interface CustomTypeOptions { + resources: { + 'commands/choice': { + name: 'choice'; + description: 'Get a random value from a set of choices'; + // ... + }; + // ... + }; + } +} +``` + +The command reads the JSON files inside a directory writes their contents into the `.d.ts` file. This is needed because `typeof import(pathToJSON)` requires the JSON files to be included in `tsconfig.json`, which may be undesirable, and because it types the keys and their inferred types, but does not load the exact values, which breaks i18next's ability to extract arguments from strings. + +This utility does not provide formatting options, so `prettier` is used under the hood to format it before writing to a file, you may opt-out using `--no-prettier` if you want to use a different tool. + +> **Note**: If you want to customize `i18next`'s `CustomTypeOptions` to add [extra options](https://www.i18next.com/overview/typescript), create a different file, TypeScript will merge the two of them. diff --git a/packages/i18next-type-generator/package.json b/packages/i18next-type-generator/package.json new file mode 100644 index 0000000..262ea5f --- /dev/null +++ b/packages/i18next-type-generator/package.json @@ -0,0 +1,66 @@ +{ + "name": "@wolfstar/i18next-type-generator", + "version": "3.0.1", + "description": "A fast utility that generates the TypeScript augmentation for i18next.", + "keywords": [ + "api", + "discord", + "http", + "i18next", + "pnpm", + "ts", + "typescript", + "wolfstar" + ], + "bugs": { + "url": "https://github.com/wolfstar-project/stars-components/issues" + }, + "license": "Apache-2.0", + "author": "@wolfstar", + "repository": { + "type": "git", + "url": "git+https://github.com/wolfstar-project/stars-components.git", + "directory": "packages/i18next-type-generator" + }, + "bin": { + "i18next-type-generator": "./dist/cli.js" + }, + "files": [ + "dist/" + ], + "type": "module", + "sideEffects": false, + "main": "dist/cli.js", + "exports": { + ".": { + "default": "./dist/cli.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown --config-loader unrun", + "watch": "tsdown --config-loader unrun --watch", + "typecheck": "tsc -p ../../tsconfig.json", + "lint": "oxlint src --fix", + "prepack": "pnpm run build" + }, + "dependencies": { + "colorette": "^2.0.20", + "commander": "^13.1.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@types/node": "22.15.21", + "i18next": "^25.10.10", + "tsdown": "^0.22.2", + "typescript": "~5.8.3" + }, + "optionalDependencies": { + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/i18next-type-generator/src/cli.ts b/packages/i18next-type-generator/src/cli.ts new file mode 100644 index 0000000..cb33223 --- /dev/null +++ b/packages/i18next-type-generator/src/cli.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import { Command, InvalidArgumentError } from 'commander'; +import { readFile } from 'node:fs/promises'; +import { generate } from './generate.js'; + +const cli = new Command(); + +const packageFile = new URL('../package.json', import.meta.url); +const packageJson = JSON.parse(await readFile(packageFile, 'utf-8')); + +const indentationParser = (value: string) => { + if (value === 'tabs') return '\t'; + + const parsed = Number(value); + if (Number.isNaN(parsed)) throw new InvalidArgumentError('The indentation must be a number or "tabs"'); + return ' '.repeat(parsed); +}; + +cli.name('i18next-type-generator') // + .version(packageJson.version) + .argument('[source]', 'The directory to generate types from', './src/locales/en-US/') + .argument('[destination]', 'The directory to generate types to', './src/@types/i18next.d.ts') + .option('-i, --indentation ', 'The indentation to use', indentationParser, '\t') + .option('-v, --verbose', 'Verbose output') + .option('--no-prettier', 'Disable prettier') + .action((...args) => generate(args, cli.opts())); + +cli.parse(process.argv); diff --git a/packages/i18next-type-generator/src/generate.ts b/packages/i18next-type-generator/src/generate.ts new file mode 100644 index 0000000..cd5d31d --- /dev/null +++ b/packages/i18next-type-generator/src/generate.ts @@ -0,0 +1,84 @@ +import { gray, green, italic } from 'colorette'; +import { mkdir, opendir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { inspect } from 'node:util'; + +const ci = 'CI' in process.env && process.env.CI !== 'false'; + +const FileIcon = ci ? '๐Ÿ“„' : '\uE628'; +const DirectoryIcon = ci ? '๐Ÿ“‚' : '\uF413'; + +const i1 = '\t'; +const i2 = i1.repeat(2); +const i3 = i1.repeat(3); + +/** + * Recursively walk through the directory and generate the types. + * @param path The path to walk through. + * @param namespace The namespace to use. + */ +async function recurse(lines: string[], path: string, namespace: string, options: GenerateOptions) { + if (options.verbose) console.log(gray(`Reading directory ${DirectoryIcon} ${green(path)}...`)); + + for await (const dirent of await opendir(path)) { + const file = join(path, dirent.name); + if (dirent.isFile()) { + if (!dirent.name.endsWith('.json')) continue; + if (options.verbose) console.log(gray(`Processing ${FileIcon} ${green(file)}...`)); + + const name = dirent.name.slice(0, -5); + const key = namespace ? `'${namespace}/${name}'` : name; + const data = JSON.stringify(JSON.parse(await readFile(file, 'utf8')), undefined, i1); + lines.push(`${i3}${key}: ${data.replaceAll('\n', `\n${i3}`)};`); + } else if (dirent.isDirectory()) { + await recurse(lines, file, namespace ? `${namespace}/${dirent.name}` : dirent.name, options); + } + } +} + +export async function generate([source, destination]: string[], options: GenerateOptions) { + const sourceDirectory = resolve(source); + const destinationFile = resolve(destination); + + if (options.verbose) { + const lines = [ + `Source: ${DirectoryIcon} ${green(sourceDirectory)}...`, + `Output: ${FileIcon} ${green(destinationFile)}...`, + '', + 'Options:', + ` - Verbose: ${green(options.verbose ? 'yes' : 'no')}` + ]; + console.log(italic(gray(lines.join('\n')))); + } + + const lines = [ + '// This file is automatically generated, do not edit it.', + "import 'i18next';", + '', + "declare module 'i18next' {", + `${i1}interface CustomTypeOptions {`, + `${i2}resources: {` + ]; + + await recurse(lines, sourceDirectory, '', options); + lines.push(`${i2}};`, `${i1}}`, '}', ''); + + let generatedSource = lines.join('\n'); + if (options.prettier) { + if (options.verbose) console.log(gray(`Loading prettier...`)); + + const { resolveConfig, format } = await import('prettier'); + const config = await resolveConfig(destinationFile); + if (options.verbose) console.log(gray(`Formatting with prettier config: ${inspect(config, { colors: true })}`)); + + generatedSource = await format(generatedSource, { ...config, filepath: destinationFile }); + } + + await mkdir(dirname(destinationFile), { recursive: true }); + await writeFile(destinationFile, generatedSource, 'utf8'); +} + +interface GenerateOptions { + verbose: boolean; + prettier: boolean; +} diff --git a/packages/i18next-type-generator/tsdown.config.ts b/packages/i18next-type-generator/tsdown.config.ts new file mode 100644 index 0000000..d78c1df --- /dev/null +++ b/packages/i18next-type-generator/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; +import { createTsdownOptions } from '../../scripts/tsdown.config'; + +export default defineConfig({ + ...createTsdownOptions({ + cjsOptions: { disabled: true }, + entry: ['src/cli.ts'], + esmOptions: { outDir: 'dist', dts: false }, + target: 'es2022' + }), + attw: false, + publint: { enabled: true, level: 'error' } +}); diff --git a/packages/shared-http-pieces/package.json b/packages/shared-http-pieces/package.json index 19e507f..2df075d 100644 --- a/packages/shared-http-pieces/package.json +++ b/packages/shared-http-pieces/package.json @@ -50,6 +50,7 @@ "watch": "tsdown --config-loader unrun --watch", "typecheck": "tsc -p ../../tsconfig.json", "lint": "oxlint src --fix", + "generate:i18n": "node ../i18next-type-generator/dist/cli.js ./src/locales/en-US/ ./src/@types/i18next.d.ts", "prepack": "pnpm run build" }, "dependencies": { @@ -63,6 +64,7 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@wolfstar/i18next-type-generator": "workspace:^", "typescript": "~5.8.3" }, "engines": { diff --git a/packages/shared-http-pieces/src/@types/i18next-custom.d.ts b/packages/shared-http-pieces/src/@types/i18next-custom.d.ts new file mode 100644 index 0000000..2ed9965 --- /dev/null +++ b/packages/shared-http-pieces/src/@types/i18next-custom.d.ts @@ -0,0 +1,11 @@ +import 'i18next'; + +declare module 'i18next' { + interface CustomTypeOptions { + resources: { + 'commands/shared': { + infoEmbedDescription: string; + }; + }; + } +} diff --git a/packages/shared-http-pieces/src/@types/i18next.d.ts b/packages/shared-http-pieces/src/@types/i18next.d.ts new file mode 100644 index 0000000..3b2d57b --- /dev/null +++ b/packages/shared-http-pieces/src/@types/i18next.d.ts @@ -0,0 +1,21 @@ +// This file is automatically generated, do not edit it. +import 'i18next'; + +declare module 'i18next' { + interface CustomTypeOptions { + resources: { + 'commands/shared': { + infoName: 'info'; + infoDescription: 'Provides information about me, and links for adding the bot and joining the support server'; + infoFieldUptimeTitle: 'Uptime'; + infoFieldUptimeValue: 'โ€ข **Host**: {{host}}\nโ€ข **Client**: {{client}}'; + infoFieldServerUsageTitle: 'Server Usage'; + infoFieldServerUsageValue: 'โ€ข **CPU Usage**: {{cpu}}\nโ€ข **Memory**: {{heapUsed}}MB (Total: {{heapTotal}}MB)'; + infoButtonInvite: 'Add me to your server!'; + infoButtonSupport: 'Support server'; + infoButtonGitHub: 'GitHub Repository'; + infoButtonDonate: 'Donate'; + }; + }; + } +} diff --git a/packages/shared-http-pieces/src/commands/info.ts b/packages/shared-http-pieces/src/commands/info.ts index f00eac9..5b3c3af 100644 --- a/packages/shared-http-pieces/src/commands/info.ts +++ b/packages/shared-http-pieces/src/commands/info.ts @@ -1,6 +1,6 @@ import { EmbedBuilder, time, TimestampStyles } from '@discordjs/builders'; import { Command, container, RegisterCommand } from '@wolfstar/http-framework'; -import { applyLocalizedBuilder, getSupportedUserLanguageT, type TFunction } from '@wolfstar/http-framework-i18n'; +import { applyLocalizedBuilder, getSupportedUserLanguageName, getT, type TFunction } from '@wolfstar/http-framework-i18n'; import { ButtonStyle, ComponentType, @@ -10,48 +10,50 @@ import { type APIEmbedField } from 'discord-api-types/v10'; import { cpus, uptime, type CpuInfo } from 'node:os'; -import { LanguageKeys } from '../lib/i18n/LanguageKeys.js'; import { getInvite, getRepository } from '../lib/information.js'; +type TranslateFn = TFunction<'commands/shared'>; + @RegisterCommand((builder) => applyLocalizedBuilder(builder, 'commands/shared:info')) export class SharedCommand extends Command { public override chatInputRun(interaction: Command.ChatInputInteraction) { - const t = getSupportedUserLanguageT(interaction); + const lng = getSupportedUserLanguageName(interaction); + const t = getT(lng, 'commands/shared'); const embed = new EmbedBuilder() - .setDescription(t(LanguageKeys.Commands.Shared.InfoEmbedDescription)) - .addFields(this.getUptimeStatistics(t), this.getServerUsageStatistics(t)); + .setDescription(t('infoEmbedDescription')) + .addFields(this.getUptimeStatistics(t), this.getServerUsageStatistics(t, lng)); const components = this.getComponents(t); return interaction.reply({ embeds: [embed.toJSON()], components, flags: MessageFlags.Ephemeral }); } - private getUptimeStatistics(t: TFunction): APIEmbedField { + private getUptimeStatistics(t: TranslateFn): APIEmbedField { const now = Date.now(); const nowSeconds = Math.round(now / 1000); return { - name: t(LanguageKeys.Commands.Shared.InfoFieldUptimeTitle), - value: t(LanguageKeys.Commands.Shared.InfoFieldUptimeValue, { + name: t('infoFieldUptimeTitle'), + value: t('infoFieldUptimeValue', { host: time(Math.round(nowSeconds - uptime()), TimestampStyles.RelativeTime), client: time(Math.round(nowSeconds - process.uptime()), TimestampStyles.RelativeTime) }) }; } - private getServerUsageStatistics(t: TFunction): APIEmbedField { + private getServerUsageStatistics(t: TranslateFn, lng: string): APIEmbedField { const usage = process.memoryUsage(); return { - name: t(LanguageKeys.Commands.Shared.InfoFieldServerUsageTitle), - value: t(LanguageKeys.Commands.Shared.InfoFieldServerUsageValue, { + name: t('infoFieldServerUsageTitle'), + value: t('infoFieldServerUsageValue', { cpu: cpus().map(SharedCommand.formatCpuInfo.bind(null)).join(' | '), - heapUsed: (usage.heapUsed / 1048576).toLocaleString(t.lng, { maximumFractionDigits: 2 }), - heapTotal: (usage.heapTotal / 1048576).toLocaleString(t.lng, { maximumFractionDigits: 2 }) + heapUsed: (usage.heapUsed / 1048576).toLocaleString(lng, { maximumFractionDigits: 2 }), + heapTotal: (usage.heapTotal / 1048576).toLocaleString(lng, { maximumFractionDigits: 2 }) }) }; } - private getComponents(t: TFunction) { + private getComponents(t: TranslateFn) { const url = getInvite(); const support = this.getSupportComponent(t); const github = this.getGitHubComponent(t); @@ -67,41 +69,41 @@ export class SharedCommand extends Command { return { type: ComponentType.ActionRow, components }; } - private getSupportComponent(t: TFunction): APIComponentInMessageActionRow { + private getSupportComponent(t: TranslateFn): APIComponentInMessageActionRow { return { type: ComponentType.Button, style: ButtonStyle.Link, - label: t(LanguageKeys.Commands.Shared.InfoButtonSupport), + label: t('infoButtonSupport'), emoji: { name: '๐Ÿ†˜' }, url: 'https://discord.gg/6gakFR2' }; } - private getInviteComponent(t: TFunction, url: string): APIComponentInMessageActionRow { + private getInviteComponent(t: TranslateFn, url: string): APIComponentInMessageActionRow { return { type: ComponentType.Button, style: ButtonStyle.Link, - label: t(LanguageKeys.Commands.Shared.InfoButtonInvite), + label: t('infoButtonInvite'), emoji: { name: '๐ŸŽ‰' }, url }; } - private getGitHubComponent(t: TFunction): APIComponentInMessageActionRow { + private getGitHubComponent(t: TranslateFn): APIComponentInMessageActionRow { return { type: ComponentType.Button, style: ButtonStyle.Link, - label: t(LanguageKeys.Commands.Shared.InfoButtonGitHub), + label: t('infoButtonGitHub'), emoji: { id: '950888087188283422', name: 'github2' }, url: getRepository() }; } - private getDonateComponent(t: TFunction): APIComponentInMessageActionRow { + private getDonateComponent(t: TranslateFn): APIComponentInMessageActionRow { return { type: ComponentType.Button, style: ButtonStyle.Link, - label: t(LanguageKeys.Commands.Shared.InfoButtonDonate), + label: t('infoButtonDonate'), emoji: { name: '๐Ÿงก' }, url: 'https://donate.wolfstar.rocks' }; diff --git a/packages/shared-http-pieces/src/index.ts b/packages/shared-http-pieces/src/index.ts index 2e3e6aa..d5ce119 100644 --- a/packages/shared-http-pieces/src/index.ts +++ b/packages/shared-http-pieces/src/index.ts @@ -1,7 +1,6 @@ /// export * as Sentry from '@sentry/node'; -export * from './lib/i18n/LanguageKeys.js'; export * from './lib/information.js'; export * from './lib/sentry.js'; export * from './lib/utilities/register-command.js'; diff --git a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys.ts b/packages/shared-http-pieces/src/lib/i18n/LanguageKeys.ts deleted file mode 100644 index b211b0a..0000000 --- a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys.ts +++ /dev/null @@ -1 +0,0 @@ -export * as LanguageKeys from './LanguageKeys/All.js'; diff --git a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/All.ts b/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/All.ts deleted file mode 100644 index b05ec06..0000000 --- a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/All.ts +++ /dev/null @@ -1 +0,0 @@ -export * as Commands from './Commands/All.js'; diff --git a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/All.ts b/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/All.ts deleted file mode 100644 index 60bb033..0000000 --- a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/All.ts +++ /dev/null @@ -1 +0,0 @@ -export * as Shared from './Shared.js'; diff --git a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/Shared.ts b/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/Shared.ts deleted file mode 100644 index a1e0c57..0000000 --- a/packages/shared-http-pieces/src/lib/i18n/LanguageKeys/Commands/Shared.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { FT, T } from '@wolfstar/http-framework-i18n'; - -export const DonateName = T('commands/shared:donateName'); -export const DonateDescription = T('commands/shared:donateDescription'); -export const DonateResponse = FT<{ urls: string }>('commands/shared:donateResponse'); -export const InfoName = T('commands/shared:inviteName'); -export const InfoDescription = T('commands/shared:inviteDescription'); -export const InfoEmbedDescription = T('commands/shared:infoEmbedDescription'); -export const InfoFieldUptimeTitle = T('commands/shared:infoFieldUptimeTitle'); -export const InfoFieldUptimeValue = FT<{ host: string; client: string }>('commands/shared:infoFieldUptimeValue'); -export const InfoFieldServerUsageTitle = T('commands/shared:infoFieldServerUsageTitle'); -export const InfoFieldServerUsageValue = FT<{ cpu: string; heapUsed: string; heapTotal: string }>('commands/shared:infoFieldServerUsageValue'); -export const InfoButtonInvite = T('commands/shared:infoButtonInvite'); -export const InfoButtonSupport = T('commands/shared:infoButtonSupport'); -export const InfoButtonGitHub = T('commands/shared:infoButtonGitHub'); -export const InfoButtonDonate = T('commands/shared:infoButtonDonate'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc629de..997d588 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,8 +161,8 @@ importers: specifier: ^0.38.8 version: 0.38.48 i18next: - specifier: ^22.5.1 - version: 22.5.1 + specifier: ^25.10.10 + version: 25.10.10(typescript@5.8.3) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -193,6 +193,35 @@ importers: specifier: ~5.8.3 version: 5.8.3 + packages/i18next-type-generator: + dependencies: + colorette: + specifier: ^2.0.20 + version: 2.0.20 + commander: + specifier: ^13.1.0 + version: 13.1.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@types/node': + specifier: 22.15.21 + version: 22.15.21 + i18next: + specifier: ^25.10.10 + version: 25.10.10(typescript@5.8.3) + tsdown: + specifier: ^0.22.2 + version: 0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@5.8.3)(unrun@0.3.1) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + optionalDependencies: + prettier: + specifier: ^3.5.3 + version: 3.8.3 + packages/influx-utilities: dependencies: '@influxdata/influxdb-client': @@ -305,6 +334,9 @@ importers: specifier: ^2.8.1 version: 2.8.1 devDependencies: + '@wolfstar/i18next-type-generator': + specifier: workspace:^ + version: link:../i18next-type-generator typescript: specifier: ~5.8.3 version: 5.8.3 @@ -1897,6 +1929,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commitizen@4.3.1: resolution: {integrity: sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==} engines: {node: '>= 12'} @@ -2291,9 +2327,6 @@ packages: engines: {node: '>=18'} hasBin: true - i18next@22.5.1: - resolution: {integrity: sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==} - i18next@25.10.10: resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} peerDependencies: @@ -2885,6 +2918,11 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4957,6 +4995,8 @@ snapshots: commander@12.1.0: {} + commander@13.1.0: {} + commitizen@4.3.1(@types/node@22.15.21)(typescript@5.8.3): dependencies: cachedir: 2.3.0 @@ -5342,10 +5382,6 @@ snapshots: husky@9.1.7: {} - i18next@22.5.1: - dependencies: - '@babel/runtime': 7.29.7 - i18next@25.10.10(typescript@5.8.3): dependencies: '@babel/runtime': 7.29.7 @@ -5896,6 +5932,9 @@ snapshots: prettier@2.8.8: {} + prettier@3.8.3: + optional: true + proc-log@5.0.0: {} promise-retry@2.0.1: diff --git a/scripts/clean.mjs b/scripts/clean.mjs index 71895a6..eb21499 100644 --- a/scripts/clean.mjs +++ b/scripts/clean.mjs @@ -10,6 +10,7 @@ const paths = [ new URL('http-framework/dist/', packagesDir), new URL('http-framework-i18n/dist/', packagesDir), new URL('i18next-backend/dist/', packagesDir), + new URL('i18next-type-generator/dist/', packagesDir), new URL('logger/dist/', packagesDir), new URL('safe-fetch/dist/', packagesDir), new URL('shared-http-pieces/dist/', packagesDir), @@ -25,6 +26,7 @@ const paths = [ new URL('http-framework/.turbo/', packagesDir), new URL('http-framework-i18n/.turbo/', packagesDir), new URL('i18next-backend/.turbo/', packagesDir), + new URL('i18next-type-generator/.turbo/', packagesDir), new URL('logger/.turbo/', packagesDir), new URL('safe-fetch/.turbo/', packagesDir), new URL('shared-http-pieces/.turbo/', packagesDir), From 1990deefd62a05fb41f12b71592a2d201aa2a801 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 18 Jun 2026 11:38:24 +0000 Subject: [PATCH 2/6] fix(i18next-type-generator): wire --indentation flag through generate The CLI parsed --indentation but generate ignored it and always used hardcoded tab indentation for the output structure and embedded JSON. --- packages/i18next-type-generator/src/generate.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/i18next-type-generator/src/generate.ts b/packages/i18next-type-generator/src/generate.ts index cd5d31d..b95fa28 100644 --- a/packages/i18next-type-generator/src/generate.ts +++ b/packages/i18next-type-generator/src/generate.ts @@ -8,16 +8,14 @@ const ci = 'CI' in process.env && process.env.CI !== 'false'; const FileIcon = ci ? '๐Ÿ“„' : '\uE628'; const DirectoryIcon = ci ? '๐Ÿ“‚' : '\uF413'; -const i1 = '\t'; -const i2 = i1.repeat(2); -const i3 = i1.repeat(3); - /** * Recursively walk through the directory and generate the types. * @param path The path to walk through. * @param namespace The namespace to use. */ async function recurse(lines: string[], path: string, namespace: string, options: GenerateOptions) { + const indent = options.indentation.repeat(3); + if (options.verbose) console.log(gray(`Reading directory ${DirectoryIcon} ${green(path)}...`)); for await (const dirent of await opendir(path)) { @@ -28,8 +26,8 @@ async function recurse(lines: string[], path: string, namespace: string, options const name = dirent.name.slice(0, -5); const key = namespace ? `'${namespace}/${name}'` : name; - const data = JSON.stringify(JSON.parse(await readFile(file, 'utf8')), undefined, i1); - lines.push(`${i3}${key}: ${data.replaceAll('\n', `\n${i3}`)};`); + const data = JSON.stringify(JSON.parse(await readFile(file, 'utf8')), undefined, options.indentation); + lines.push(`${indent}${key}: ${data.replaceAll('\n', `\n${indent}`)};`); } else if (dirent.isDirectory()) { await recurse(lines, file, namespace ? `${namespace}/${dirent.name}` : dirent.name, options); } @@ -39,6 +37,8 @@ async function recurse(lines: string[], path: string, namespace: string, options export async function generate([source, destination]: string[], options: GenerateOptions) { const sourceDirectory = resolve(source); const destinationFile = resolve(destination); + const i1 = options.indentation; + const i2 = i1.repeat(2); if (options.verbose) { const lines = [ @@ -46,7 +46,8 @@ export async function generate([source, destination]: string[], options: Generat `Output: ${FileIcon} ${green(destinationFile)}...`, '', 'Options:', - ` - Verbose: ${green(options.verbose ? 'yes' : 'no')}` + ` - Verbose: ${green(options.verbose ? 'yes' : 'no')}`, + ` - Indentation: ${green(JSON.stringify(options.indentation))}` ]; console.log(italic(gray(lines.join('\n')))); } @@ -81,4 +82,5 @@ export async function generate([source, destination]: string[], options: Generat interface GenerateOptions { verbose: boolean; prettier: boolean; + indentation: string; } From 2ae20d3cc59d2991b8421bcfcba3fa2684659566 Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 18 Jun 2026 20:16:09 +0200 Subject: [PATCH 3/6] feat(i18next-type-generator): enhance CLI and output generation - Replace `commander` with `citty` for command-line interface handling. - Add support for `oxfmt` and `prettier` formatting options in the generation process. - Introduce file annotations to suppress linting and formatting issues in generated files. - Update package dependencies and scripts, including the addition of a test script using `vitest`. - Improve README documentation to reflect new features and usage instructions. --- packages/i18next-type-generator/README.md | 8 ++ packages/i18next-type-generator/package.json | 9 +- packages/i18next-type-generator/src/cli.ts | 83 ++++++++++--- .../i18next-type-generator/src/generate.ts | 109 +++++++++++++++--- .../tests/generate.test.ts | 25 ++++ .../i18next-type-generator/vitest.config.ts | 4 + .../src/@types/i18next.d.ts | 42 ++++--- pnpm-lock.yaml | 21 ++-- 8 files changed, 240 insertions(+), 61 deletions(-) create mode 100644 packages/i18next-type-generator/tests/generate.test.ts create mode 100644 packages/i18next-type-generator/vitest.config.ts diff --git a/packages/i18next-type-generator/README.md b/packages/i18next-type-generator/README.md index 7e9dbf9..d9681f3 100644 --- a/packages/i18next-type-generator/README.md +++ b/packages/i18next-type-generator/README.md @@ -22,8 +22,14 @@ This CLI tool generates a `.d.ts` file with the following structure: ```typescript // This file is automatically generated, do not edit it. +/* eslint-disable */ +/* oxlint-disable */ +// oxfmt-ignore +// prettier-ignore import 'i18next'; +// oxfmt-ignore +// prettier-ignore declare module 'i18next' { interface CustomTypeOptions { resources: { @@ -38,6 +44,8 @@ declare module 'i18next' { } ``` +The output includes `eslint-disable` / `oxlint-disable` comments to suppress linting, and `oxfmt-ignore` / `prettier-ignore` comments before each top-level statement to prevent formatters from rewriting the generated file. These ignore directives are always emitted; the `--no-oxfmt` and `--no-prettier` flags only affect the internal formatting pass used to produce the resource block, not the emitted ignore comments. + The command reads the JSON files inside a directory writes their contents into the `.d.ts` file. This is needed because `typeof import(pathToJSON)` requires the JSON files to be included in `tsconfig.json`, which may be undesirable, and because it types the keys and their inferred types, but does not load the exact values, which breaks i18next's ability to extract arguments from strings. This utility does not provide formatting options, so `prettier` is used under the hood to format it before writing to a file, you may opt-out using `--no-prettier` if you want to use a different tool. diff --git a/packages/i18next-type-generator/package.json b/packages/i18next-type-generator/package.json index 262ea5f..49b5ccd 100644 --- a/packages/i18next-type-generator/package.json +++ b/packages/i18next-type-generator/package.json @@ -12,6 +12,7 @@ "typescript", "wolfstar" ], + "homepage": "https://github.com/wolfstar-project/stars-components/tree/main/packages/i18next-type-generator", "bugs": { "url": "https://github.com/wolfstar-project/stars-components/issues" }, @@ -40,6 +41,7 @@ "access": "public" }, "scripts": { + "test": "vitest run", "build": "tsdown --config-loader unrun", "watch": "tsdown --config-loader unrun --watch", "typecheck": "tsc -p ../../tsconfig.json", @@ -47,17 +49,20 @@ "prepack": "pnpm run build" }, "dependencies": { + "citty": "^0.1.6", "colorette": "^2.0.20", - "commander": "^13.1.0", "tslib": "^2.8.1" }, "devDependencies": { "@types/node": "22.15.21", + "@vitest/coverage-v8": "^4.1.8", "i18next": "^25.10.10", "tsdown": "^0.22.2", - "typescript": "~5.8.3" + "typescript": "~5.8.3", + "vitest": "^4.1.8" }, "optionalDependencies": { + "oxfmt": "^0.53.0", "prettier": "^3.5.3" }, "engines": { diff --git a/packages/i18next-type-generator/src/cli.ts b/packages/i18next-type-generator/src/cli.ts index cb33223..c31de8f 100644 --- a/packages/i18next-type-generator/src/cli.ts +++ b/packages/i18next-type-generator/src/cli.ts @@ -1,29 +1,84 @@ #!/usr/bin/env node -import { Command, InvalidArgumentError } from 'commander'; +import { defineCommand, runCommand, showUsage } from 'citty'; import { readFile } from 'node:fs/promises'; import { generate } from './generate.js'; -const cli = new Command(); - const packageFile = new URL('../package.json', import.meta.url); const packageJson = JSON.parse(await readFile(packageFile, 'utf-8')); -const indentationParser = (value: string) => { +const parseIndentation = (value: string): string => { if (value === 'tabs') return '\t'; const parsed = Number(value); - if (Number.isNaN(parsed)) throw new InvalidArgumentError('The indentation must be a number or "tabs"'); + if (Number.isNaN(parsed)) throw new Error('The indentation must be a number or "tabs"'); return ' '.repeat(parsed); }; -cli.name('i18next-type-generator') // - .version(packageJson.version) - .argument('[source]', 'The directory to generate types from', './src/locales/en-US/') - .argument('[destination]', 'The directory to generate types to', './src/@types/i18next.d.ts') - .option('-i, --indentation ', 'The indentation to use', indentationParser, '\t') - .option('-v, --verbose', 'Verbose output') - .option('--no-prettier', 'Disable prettier') - .action((...args) => generate(args, cli.opts())); +const main = defineCommand({ + meta: { + name: 'i18next-type-generator', + version: packageJson.version, + description: packageJson.description + }, + args: { + source: { + type: 'positional', + description: 'The directory to generate types from', + default: './src/locales/en-US/' + }, + destination: { + type: 'positional', + description: 'The directory to generate types to', + default: './src/@types/i18next.d.ts' + }, + indentation: { + type: 'string', + description: 'The indentation to use', + alias: 'i', + default: '\t' + }, + verbose: { + type: 'boolean', + description: 'Verbose output', + alias: 'v' + }, + prettier: { + type: 'boolean', + description: 'Format output with prettier', + default: true + }, + oxfmt: { + type: 'boolean', + description: 'Format output with oxfmt', + default: true + } + }, + async run({ args }) { + await generate([args.source, args.destination], { + verbose: args.verbose, + oxfmt: args.oxfmt, + prettier: args.prettier, + indentation: parseIndentation(args.indentation) + }); + } +}); + +const rawArgs = process.argv.slice(2); + +if (rawArgs.includes('--help') || rawArgs.includes('-h')) { + await showUsage(main); + process.exit(0); +} + +if (rawArgs.includes('--version')) { + console.log(packageJson.version); + process.exit(0); +} -cli.parse(process.argv); +try { + await runCommand(main, { rawArgs }); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/packages/i18next-type-generator/src/generate.ts b/packages/i18next-type-generator/src/generate.ts index b95fa28..00fbfff 100644 --- a/packages/i18next-type-generator/src/generate.ts +++ b/packages/i18next-type-generator/src/generate.ts @@ -1,4 +1,4 @@ -import { gray, green, italic } from 'colorette'; +import { gray, green, italic, yellow } from 'colorette'; import { mkdir, opendir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { inspect } from 'node:util'; @@ -8,6 +8,28 @@ const ci = 'CI' in process.env && process.env.CI !== 'false'; const FileIcon = ci ? '๐Ÿ“„' : '\uE628'; const DirectoryIcon = ci ? '๐Ÿ“‚' : '\uF413'; +const GENERATED_FILE_HEADER = ['// This file is automatically generated, do not edit it.', '/* eslint-disable */', '/* oxlint-disable */'] as const; + +const FORMATTER_IGNORE = ['// oxfmt-ignore', '// prettier-ignore'] as const; + +/** + * Inject formatter and linter ignore directives into the already-formatted output. + * + * The file-level `eslint-disable` / `oxlint-disable` comments suppress all lint rules, while the + * per-statement `oxfmt-ignore` / `prettier-ignore` comments protect the two top-level statements + * (the `i18next` import and the `declare module` block), since neither formatter supports a + * whole-file inline ignore. + * @param source The formatted generated source. + */ +export function annotateGeneratedFile(source: string): string { + const ignore = FORMATTER_IGNORE.join('\n'); + const annotated = source + .replace(/^(import\s+['"]i18next['"])/m, `${ignore}\n$1`) + .replace(/^(declare\s+module\s+['"]i18next['"])/m, `${ignore}\n$1`); + + return `${GENERATED_FILE_HEADER.join('\n')}\n${annotated}`; +} + /** * Recursively walk through the directory and generate the types. * @param path The path to walk through. @@ -18,7 +40,13 @@ async function recurse(lines: string[], path: string, namespace: string, options if (options.verbose) console.log(gray(`Reading directory ${DirectoryIcon} ${green(path)}...`)); + const entries = []; for await (const dirent of await opendir(path)) { + entries.push(dirent); + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const dirent of entries) { const file = join(path, dirent.name); if (dirent.isFile()) { if (!dirent.name.endsWith('.json')) continue; @@ -34,6 +62,64 @@ async function recurse(lines: string[], path: string, namespace: string, options } } +async function formatWithOxfmt(source: string, destination: string, options: GenerateOptions): Promise { + if (!options.oxfmt) return; + + try { + if (options.verbose) console.log(gray('Loading oxfmt...')); + + const { format } = await import('oxfmt'); + const { code, errors } = await format(destination, source); + if (errors.length > 0) { + throw new Error(errors.map((error) => error.message).join('\n')); + } + + if (options.verbose) console.log(gray('Formatting with oxfmt.')); + + return code; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + yellow( + options.verbose + ? `Failed to format with oxfmt: ${message}. Continuing with unformatted output.` + : 'Failed to format with oxfmt; install it as a dependency or use --no-oxfmt. Continuing with unformatted output.' + ) + ); + } + + return undefined; +} + +async function formatWithPrettier(source: string, destination: string, options: GenerateOptions): Promise { + if (!options.prettier) return; + + try { + if (options.verbose) console.log(gray('Loading prettier...')); + + const { resolveConfig, format } = await import('prettier'); + const config = await resolveConfig(destination); + if (options.verbose) console.log(gray(`Formatting with prettier config: ${inspect(config, { colors: true })}`)); + + return await format(source, { ...config, filepath: destination }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + yellow( + options.verbose + ? `Failed to format with prettier: ${message}. Continuing with unformatted output.` + : 'Failed to format with prettier; install it as a dependency or use --no-prettier. Continuing with unformatted output.' + ) + ); + } + + return undefined; +} + +async function formatSource(source: string, destination: string, options: GenerateOptions) { + return (await formatWithOxfmt(source, destination, options)) ?? (await formatWithPrettier(source, destination, options)) ?? source; +} + export async function generate([source, destination]: string[], options: GenerateOptions) { const sourceDirectory = resolve(source); const destinationFile = resolve(destination); @@ -52,28 +138,14 @@ export async function generate([source, destination]: string[], options: Generat console.log(italic(gray(lines.join('\n')))); } - const lines = [ - '// This file is automatically generated, do not edit it.', - "import 'i18next';", - '', - "declare module 'i18next' {", - `${i1}interface CustomTypeOptions {`, - `${i2}resources: {` - ]; + const lines = ["import 'i18next';", '', "declare module 'i18next' {", `${i1}interface CustomTypeOptions {`, `${i2}resources: {`]; await recurse(lines, sourceDirectory, '', options); lines.push(`${i2}};`, `${i1}}`, '}', ''); let generatedSource = lines.join('\n'); - if (options.prettier) { - if (options.verbose) console.log(gray(`Loading prettier...`)); - - const { resolveConfig, format } = await import('prettier'); - const config = await resolveConfig(destinationFile); - if (options.verbose) console.log(gray(`Formatting with prettier config: ${inspect(config, { colors: true })}`)); - - generatedSource = await format(generatedSource, { ...config, filepath: destinationFile }); - } + generatedSource = await formatSource(generatedSource, destinationFile, options); + generatedSource = annotateGeneratedFile(generatedSource); await mkdir(dirname(destinationFile), { recursive: true }); await writeFile(destinationFile, generatedSource, 'utf8'); @@ -81,6 +153,7 @@ export async function generate([source, destination]: string[], options: Generat interface GenerateOptions { verbose: boolean; + oxfmt: boolean; prettier: boolean; indentation: string; } diff --git a/packages/i18next-type-generator/tests/generate.test.ts b/packages/i18next-type-generator/tests/generate.test.ts new file mode 100644 index 0000000..15f4a61 --- /dev/null +++ b/packages/i18next-type-generator/tests/generate.test.ts @@ -0,0 +1,25 @@ +import { annotateGeneratedFile } from '../src/generate.js'; + +const formatted = ["import 'i18next';", '', "declare module 'i18next' {", '\tinterface CustomTypeOptions {}', '}', ''].join('\n'); + +describe('annotateGeneratedFile', () => { + test('GIVEN formatted source THEN prepends the generated file header', () => { + const result = annotateGeneratedFile(formatted); + + expect( + result.startsWith(['// This file is automatically generated, do not edit it.', '/* eslint-disable */', '/* oxlint-disable */'].join('\n')) + ).toBe(true); + }); + + test('GIVEN formatted source THEN inserts formatter ignores before the import statement', () => { + const result = annotateGeneratedFile(formatted); + + expect(result).toContain(['// oxfmt-ignore', '// prettier-ignore', "import 'i18next';"].join('\n')); + }); + + test('GIVEN formatted source THEN inserts formatter ignores before the declare module statement', () => { + const result = annotateGeneratedFile(formatted); + + expect(result).toContain(['// oxfmt-ignore', '// prettier-ignore', "declare module 'i18next' {"].join('\n')); + }); +}); diff --git a/packages/i18next-type-generator/vitest.config.ts b/packages/i18next-type-generator/vitest.config.ts new file mode 100644 index 0000000..3ccfa80 --- /dev/null +++ b/packages/i18next-type-generator/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineProject, mergeConfig } from 'vitest/config'; +import configShared from '../../vitest.shared.js'; + +export default mergeConfig(configShared, defineProject({})); diff --git a/packages/shared-http-pieces/src/@types/i18next.d.ts b/packages/shared-http-pieces/src/@types/i18next.d.ts index 3b2d57b..1ef1f5e 100644 --- a/packages/shared-http-pieces/src/@types/i18next.d.ts +++ b/packages/shared-http-pieces/src/@types/i18next.d.ts @@ -1,21 +1,27 @@ // This file is automatically generated, do not edit it. -import 'i18next'; +/* eslint-disable */ +/* oxlint-disable */ +// oxfmt-ignore +// prettier-ignore +import "i18next"; -declare module 'i18next' { - interface CustomTypeOptions { - resources: { - 'commands/shared': { - infoName: 'info'; - infoDescription: 'Provides information about me, and links for adding the bot and joining the support server'; - infoFieldUptimeTitle: 'Uptime'; - infoFieldUptimeValue: 'โ€ข **Host**: {{host}}\nโ€ข **Client**: {{client}}'; - infoFieldServerUsageTitle: 'Server Usage'; - infoFieldServerUsageValue: 'โ€ข **CPU Usage**: {{cpu}}\nโ€ข **Memory**: {{heapUsed}}MB (Total: {{heapTotal}}MB)'; - infoButtonInvite: 'Add me to your server!'; - infoButtonSupport: 'Support server'; - infoButtonGitHub: 'GitHub Repository'; - infoButtonDonate: 'Donate'; - }; - }; - } +// oxfmt-ignore +// prettier-ignore +declare module "i18next" { + interface CustomTypeOptions { + resources: { + "commands/shared": { + infoName: "info"; + infoDescription: "Provides information about me, and links for adding the bot and joining the support server"; + infoFieldUptimeTitle: "Uptime"; + infoFieldUptimeValue: "โ€ข **Host**: {{host}}\nโ€ข **Client**: {{client}}"; + infoFieldServerUsageTitle: "Server Usage"; + infoFieldServerUsageValue: "โ€ข **CPU Usage**: {{cpu}}\nโ€ข **Memory**: {{heapUsed}}MB (Total: {{heapTotal}}MB)"; + infoButtonInvite: "Add me to your server!"; + infoButtonSupport: "Support server"; + infoButtonGitHub: "GitHub Repository"; + infoButtonDonate: "Donate"; + }; + }; + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 997d588..4948471 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,12 +195,12 @@ importers: packages/i18next-type-generator: dependencies: + citty: + specifier: ^0.1.6 + version: 0.1.6 colorette: specifier: ^2.0.20 version: 2.0.20 - commander: - specifier: ^13.1.0 - version: 13.1.0 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -208,6 +208,9 @@ importers: '@types/node': specifier: 22.15.21 version: 22.15.21 + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.8(vitest@4.1.8) i18next: specifier: ^25.10.10 version: 25.10.10(typescript@5.8.3) @@ -217,7 +220,13 @@ importers: typescript: specifier: ~5.8.3 version: 5.8.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.15.21)(@vitest/coverage-v8@4.1.8)(vite@8.0.14) optionalDependencies: + oxfmt: + specifier: ^0.53.0 + version: 0.53.0 prettier: specifier: ^3.5.3 version: 3.8.3 @@ -1929,10 +1938,6 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} - commitizen@4.3.1: resolution: {integrity: sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==} engines: {node: '>= 12'} @@ -4995,8 +5000,6 @@ snapshots: commander@12.1.0: {} - commander@13.1.0: {} - commitizen@4.3.1(@types/node@22.15.21)(typescript@5.8.3): dependencies: cachedir: 2.3.0 From 3c43053f7643c5b22e1014c931efe48aa811571d Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 18 Jun 2026 21:17:32 +0200 Subject: [PATCH 4/6] chore(i18next-type-generator): clean up CHANGELOG and enhance README - Remove initial release notes from CHANGELOG.md for clarity. - Update README.md to include the new `--indentation` option for controlling output formatting. --- packages/i18next-type-generator/CHANGELOG.md | 4 ---- packages/i18next-type-generator/README.md | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/i18next-type-generator/CHANGELOG.md b/packages/i18next-type-generator/CHANGELOG.md index 498646a..a1bcfb7 100644 --- a/packages/i18next-type-generator/CHANGELOG.md +++ b/packages/i18next-type-generator/CHANGELOG.md @@ -1,5 +1 @@ # @wolfstar/i18next-type-generator - -## 3.0.1 - -Initial release under the `@wolfstar` scope. diff --git a/packages/i18next-type-generator/README.md b/packages/i18next-type-generator/README.md index d9681f3..0cda5b4 100644 --- a/packages/i18next-type-generator/README.md +++ b/packages/i18next-type-generator/README.md @@ -14,6 +14,7 @@ $ i18next-type-generator [options] [source] [destination] # Options: # -V, --version output the version number # -v, --verbose Verbose output +# -i, --indentation Indentation for generated output (number of spaces or "tabs", default: tabs) # --no-prettier Disable prettier # -h, --help display help for command ``` @@ -48,6 +49,6 @@ The output includes `eslint-disable` / `oxlint-disable` comments to suppress lin The command reads the JSON files inside a directory writes their contents into the `.d.ts` file. This is needed because `typeof import(pathToJSON)` requires the JSON files to be included in `tsconfig.json`, which may be undesirable, and because it types the keys and their inferred types, but does not load the exact values, which breaks i18next's ability to extract arguments from strings. -This utility does not provide formatting options, so `prettier` is used under the hood to format it before writing to a file, you may opt-out using `--no-prettier` if you want to use a different tool. +You can control indentation with `-i` / `--indentation` (a space count or `tabs`). `prettier` is also used under the hood to format output before writing to a file; opt out with `--no-prettier` if you want to use a different tool. > **Note**: If you want to customize `i18next`'s `CustomTypeOptions` to add [extra options](https://www.i18next.com/overview/typescript), create a different file, TypeScript will merge the two of them. From 67d9424b9198f46c617b79014740dcb86c349c9d Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 18 Jun 2026 21:22:38 +0200 Subject: [PATCH 5/6] fix(i18next-type-generator): change default indentation from tab to 'tabs' in CLI --- packages/i18next-type-generator/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/i18next-type-generator/src/cli.ts b/packages/i18next-type-generator/src/cli.ts index c31de8f..aec6cc1 100644 --- a/packages/i18next-type-generator/src/cli.ts +++ b/packages/i18next-type-generator/src/cli.ts @@ -36,7 +36,7 @@ const main = defineCommand({ type: 'string', description: 'The indentation to use', alias: 'i', - default: '\t' + default: 'tabs' }, verbose: { type: 'boolean', From f5da3a7ee9640498ed5fc5b215e7fa3baab3d1bf Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 18 Jun 2026 21:41:39 +0200 Subject: [PATCH 6/6] test(i18next-type-generator): add comprehensive tests for CLI and generation features - Introduce tests for CLI commands including version and help flags. - Validate type generation with various indentation options and error handling for invalid inputs. - Implement tests for fallback mechanisms when formatting fails with `oxfmt` and `prettier`. - Ensure cleanup of temporary directories after tests to maintain isolation. --- .../i18next-type-generator/tests/cli.test.ts | 119 ++++++++++++++++++ .../tests/generate-formatter-fallback.test.ts | 46 +++++++ .../tests/generate-prettier-fallback.test.ts | 49 ++++++++ .../tests/generate.test.ts | 112 ++++++++++++++++- 4 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 packages/i18next-type-generator/tests/cli.test.ts create mode 100644 packages/i18next-type-generator/tests/generate-formatter-fallback.test.ts create mode 100644 packages/i18next-type-generator/tests/generate-prettier-fallback.test.ts diff --git a/packages/i18next-type-generator/tests/cli.test.ts b/packages/i18next-type-generator/tests/cli.test.ts new file mode 100644 index 0000000..7fa1500 --- /dev/null +++ b/packages/i18next-type-generator/tests/cli.test.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +class ProcessExit extends Error { + constructor(public readonly code: number) { + super(`process.exit(${code})`); + } +} + +async function createFixtureDirectory() { + const tempDirectory = await mkdtemp(join(tmpdir(), 'i18next-type-generator-cli-')); + const sourceDirectory = join(tempDirectory, 'locales', 'en-US'); + const destinationFile = join(tempDirectory, 'output', 'i18next.d.ts'); + + await mkdir(join(sourceDirectory, 'commands'), { recursive: true }); + await writeFile(join(sourceDirectory, 'commands', 'shared.json'), JSON.stringify({ hello: 'world' })); + + return { tempDirectory, sourceDirectory, destinationFile }; +} + +async function runCli(argv: string[]) { + vi.resetModules(); + + const originalArgv = process.argv; + process.argv = ['node', 'cli.js', ...argv]; + + const logs: string[] = []; + const errors: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + logs.push(args.map(String).join(' ')); + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + errors.push(args.map(String).join(' ')); + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ProcessExit(code ?? 0); + }); + + try { + await import('../src/cli.ts'); + return { code: 0, stdout: logs.join('\n'), stderr: errors.join('\n') }; + } catch (error) { + if (error instanceof ProcessExit) { + return { code: error.code, stdout: logs.join('\n'), stderr: errors.join('\n') }; + } + + throw error; + } finally { + process.argv = originalArgv; + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +describe('cli', () => { + let fixtureDirectory: string; + let sourceDirectory: string; + let destinationFile: string; + + beforeEach(async () => { + ({ tempDirectory: fixtureDirectory, sourceDirectory, destinationFile } = await createFixtureDirectory()); + }); + + afterEach(async () => { + await rm(fixtureDirectory, { recursive: true, force: true }); + }); + + test('GIVEN --version THEN prints the package version', async () => { + const packageJson = JSON.parse(await readFile(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')); + const { code, stdout } = await runCli(['--version']); + + expect(code).toBe(0); + expect(stdout.trim()).toBe(packageJson.version); + }); + + test('GIVEN --help THEN exits successfully', async () => { + const { code } = await runCli(['--help']); + + expect(code).toBe(0); + }); + + test('GIVEN source and destination THEN generates types', async () => { + const { code, stderr } = await runCli([sourceDirectory, destinationFile, '--no-oxfmt', '--no-prettier']); + + expect(code).toBe(0); + expect(stderr).toBe(''); + + const output = await readFile(destinationFile, 'utf8'); + expect(output).toContain('commands/shared'); + }); + + test('GIVEN invalid indentation THEN exits with an error', async () => { + const { code, stderr } = await runCli([sourceDirectory, destinationFile, '--indentation', 'invalid', '--no-oxfmt', '--no-prettier']); + + expect(code).toBe(1); + expect(stderr).toContain('indentation'); + }); + + test('GIVEN tabs indentation THEN generates types', async () => { + const { code } = await runCli([sourceDirectory, destinationFile, '--indentation', 'tabs', '--no-oxfmt', '--no-prettier']); + + expect(code).toBe(0); + + const output = await readFile(destinationFile, 'utf8'); + expect(output).toContain('\tinterface CustomTypeOptions'); + }); + + test('GIVEN numeric indentation THEN generates types', async () => { + const { code } = await runCli([sourceDirectory, destinationFile, '--indentation', '2', '--no-oxfmt', '--no-prettier']); + + expect(code).toBe(0); + + const output = await readFile(destinationFile, 'utf8'); + expect(output).toContain(' interface CustomTypeOptions'); + }); +}); diff --git a/packages/i18next-type-generator/tests/generate-formatter-fallback.test.ts b/packages/i18next-type-generator/tests/generate-formatter-fallback.test.ts new file mode 100644 index 0000000..7545cb2 --- /dev/null +++ b/packages/i18next-type-generator/tests/generate-formatter-fallback.test.ts @@ -0,0 +1,46 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('oxfmt', () => ({ + format: async () => ({ code: '', errors: [{ message: 'format failed' }] }) +})); + +const { generate } = await import('../src/generate.js'); + +describe('generate formatter fallbacks', () => { + let tempDirectory: string; + let sourceDirectory: string; + let destinationFile: string; + + beforeEach(async () => { + tempDirectory = await mkdtemp(join(tmpdir(), 'i18next-type-generator-fallback-')); + sourceDirectory = join(tempDirectory, 'locales', 'en-US'); + destinationFile = join(tempDirectory, 'output', 'i18next.d.ts'); + + await mkdir(join(sourceDirectory, 'commands'), { recursive: true }); + await writeFile(join(sourceDirectory, 'commands', 'shared.json'), JSON.stringify({ hello: 'world' })); + }); + + afterEach(async () => { + await rm(tempDirectory, { recursive: true, force: true }); + }); + + test('GIVEN oxfmt errors THEN falls back to unformatted output', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await generate([sourceDirectory, destinationFile], { + verbose: false, + oxfmt: true, + prettier: false, + indentation: '\t' + }); + + const output = await readFile(destinationFile, 'utf8'); + + expect(output).toContain('commands/shared'); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/packages/i18next-type-generator/tests/generate-prettier-fallback.test.ts b/packages/i18next-type-generator/tests/generate-prettier-fallback.test.ts new file mode 100644 index 0000000..e687237 --- /dev/null +++ b/packages/i18next-type-generator/tests/generate-prettier-fallback.test.ts @@ -0,0 +1,49 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('prettier', () => ({ + resolveConfig: async () => ({}), + format: async () => { + throw new Error('format failed'); + } +})); + +const { generate } = await import('../src/generate.js'); + +describe('generate prettier fallbacks', () => { + let tempDirectory: string; + let sourceDirectory: string; + let destinationFile: string; + + beforeEach(async () => { + tempDirectory = await mkdtemp(join(tmpdir(), 'i18next-type-generator-prettier-')); + sourceDirectory = join(tempDirectory, 'locales', 'en-US'); + destinationFile = join(tempDirectory, 'output', 'i18next.d.ts'); + + await mkdir(join(sourceDirectory, 'commands'), { recursive: true }); + await writeFile(join(sourceDirectory, 'commands', 'shared.json'), JSON.stringify({ hello: 'world' })); + }); + + afterEach(async () => { + await rm(tempDirectory, { recursive: true, force: true }); + }); + + test('GIVEN prettier errors THEN falls back to unformatted output', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await generate([sourceDirectory, destinationFile], { + verbose: false, + oxfmt: false, + prettier: true, + indentation: '\t' + }); + + const output = await readFile(destinationFile, 'utf8'); + + expect(output).toContain('commands/shared'); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/packages/i18next-type-generator/tests/generate.test.ts b/packages/i18next-type-generator/tests/generate.test.ts index 15f4a61..d8a0fcc 100644 --- a/packages/i18next-type-generator/tests/generate.test.ts +++ b/packages/i18next-type-generator/tests/generate.test.ts @@ -1,7 +1,30 @@ -import { annotateGeneratedFile } from '../src/generate.js'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { annotateGeneratedFile, generate } from '../src/generate.js'; const formatted = ["import 'i18next';", '', "declare module 'i18next' {", '\tinterface CustomTypeOptions {}', '}', ''].join('\n'); +const defaultOptions = { + verbose: false, + oxfmt: false, + prettier: false, + indentation: '\t' +} as const; + +async function writeFixture(sourceDirectory: string) { + await mkdir(join(sourceDirectory, 'commands'), { recursive: true }); + await writeFile( + join(sourceDirectory, 'commands', 'shared.json'), + JSON.stringify({ + hello: 'world', + count: 1 + }) + ); + await writeFile(join(sourceDirectory, 'readme.txt'), 'not json'); +} + describe('annotateGeneratedFile', () => { test('GIVEN formatted source THEN prepends the generated file header', () => { const result = annotateGeneratedFile(formatted); @@ -23,3 +46,90 @@ describe('annotateGeneratedFile', () => { expect(result).toContain(['// oxfmt-ignore', '// prettier-ignore', "declare module 'i18next' {"].join('\n')); }); }); + +describe('generate', () => { + let tempDirectory: string; + let sourceDirectory: string; + let destinationFile: string; + + beforeEach(async () => { + tempDirectory = await mkdtemp(join(tmpdir(), 'i18next-type-generator-')); + sourceDirectory = join(tempDirectory, 'locales', 'en-US'); + destinationFile = join(tempDirectory, 'output', 'i18next.d.ts'); + await writeFixture(sourceDirectory); + }); + + afterEach(async () => { + await rm(tempDirectory, { recursive: true, force: true }); + }); + + test('GIVEN locale JSON files THEN writes augmented i18next types', async () => { + await generate([sourceDirectory, destinationFile], defaultOptions); + + const output = await readFile(destinationFile, 'utf8'); + + expect(output).toContain('commands/shared'); + expect(output).toContain('hello'); + expect(output).toContain('world'); + expect(output).toContain("import 'i18next'"); + expect(output).toContain('interface CustomTypeOptions'); + }); + + test('GIVEN nested locale directories THEN namespaces keys', async () => { + await mkdir(join(sourceDirectory, 'guilds'), { recursive: true }); + await writeFile(join(sourceDirectory, 'guilds', 'settings.json'), JSON.stringify({ enabled: true })); + + await generate([sourceDirectory, destinationFile], defaultOptions); + + const output = await readFile(destinationFile, 'utf8'); + + expect(output).toContain('guilds/settings'); + expect(output).toContain('enabled'); + }); + + test('GIVEN space indentation THEN indents generated resources', async () => { + await generate([sourceDirectory, destinationFile], { + ...defaultOptions, + indentation: ' ' + }); + + const output = await readFile(destinationFile, 'utf8'); + + expect(output).toContain(' interface CustomTypeOptions'); + expect(output).toContain(' resources:'); + }); + + test('GIVEN verbose mode THEN logs progress without failing', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await generate([sourceDirectory, destinationFile], { + ...defaultOptions, + verbose: true + }); + + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + test('GIVEN oxfmt enabled THEN formats output', async () => { + await generate([sourceDirectory, destinationFile], { + ...defaultOptions, + oxfmt: true + }); + + const output = await readFile(destinationFile, 'utf8'); + + expect(output).toContain('commands/shared'); + }); + + test('GIVEN prettier enabled THEN formats output', async () => { + await generate([sourceDirectory, destinationFile], { + ...defaultOptions, + prettier: true + }); + + const output = await readFile(destinationFile, 'utf8'); + + expect(output).toContain('commands/shared'); + }); +});