Skip to content

Commit

Permalink
feat: add @skyra/i18next-type-generator
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet committed Jul 24, 2023
1 parent ad573b4 commit 8e3c840
Show file tree
Hide file tree
Showing 15 changed files with 348 additions and 2 deletions.
1 change: 1 addition & 0 deletions .npm-deprecaterc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package:
- '@skyra/http-framework'
- '@skyra/http-framework-i18n'
- '@skyra/i18next-backend'
- '@skyra/i18next-type-generator'
- '@skyra/logger'
- '@skyra/shared-http-pieces'
- '@skyra/start-banner'
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
[![npm](https://img.shields.io/npm/v/@skyra/http-framework?color=crimson&logo=npm&style=flat-square&label=@skyra/http-framework)](https://www.npmjs.com/package/@skyra/http-framework)
[![npm](https://img.shields.io/npm/v/@skyra/http-framework-i18n?color=crimson&logo=npm&style=flat-square&label=@skyra/http-framework-i18n)](https://www.npmjs.com/package/@skyra/http-framework-i18n)
[![npm](https://img.shields.io/npm/v/@skyra/i18next-backend?color=crimson&logo=npm&style=flat-square&label=@skyra/i18next-backend)](https://www.npmjs.com/package/@skyra/i18next-backend)
[![npm](https://img.shields.io/npm/v/@skyra/i18next-type-generator?color=crimson&logo=npm&style=flat-square&label=@skyra/i18next-type-generator)](https://www.npmjs.com/package/@skyra/i18next-type-generator)
[![npm](https://img.shields.io/npm/v/@skyra/logger?color=crimson&logo=npm&style=flat-square&label=@skyra/logger)](https://www.npmjs.com/package/@skyra/logger)
[![npm](https://img.shields.io/npm/v/@skyra/shared-http-pieces?color=crimson&logo=npm&style=flat-square&label=@skyra/shared-http-pieces)](https://www.npmjs.com/package/@skyra/shared-http-pieces)
[![npm](https://img.shields.io/npm/v/@skyra/start-banner?color=crimson&logo=npm&style=flat-square&label=@skyra/start-banner)](https://www.npmjs.com/package/@skyra/start-banner)
Expand Down
4 changes: 4 additions & 0 deletions packages/i18next-type-generator/.cliff-jumperrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: i18next-type-generator
org: skyra
install: true
packagePath: packages/i18next-type-generator
3 changes: 3 additions & 0 deletions packages/i18next-type-generator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Changelog

All notable changes to this project will be documented in this file.
45 changes: 45 additions & 0 deletions packages/i18next-type-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# `@skyra/i18next-type-generator`

A fast and modern type augmentation generator for the [`@skyra/i18next-backend`](https://www.npmjs.com/package/@skyra/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.
63 changes: 63 additions & 0 deletions packages/i18next-type-generator/cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}\
# [{{ version | trim_start_matches(pat="v") }}]\
{% if previous %}\
{% if previous.version %}\
(https://github.com/skyra-project/archid-components/compare/{{ previous.version }}...{{ version }})\
{% else %}\
(https://github.com/skyra-project/archid-components/tree/{{ version }})\
{% endif %}\
{% endif %} \
- ({{ timestamp | date(format="%Y-%m-%d") }})
{% else %}\
# [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
## {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}\
**{{commit.scope}}:** \
{% endif %}\
{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/skyra-project/archid-components/commit/{{ commit.id }}))\
{% if commit.breaking %}\
{% for breakingChange in commit.footers %}\
\n{% raw %} {% endraw %}- 💥 **{{ breakingChange.token }}{{ breakingChange.separator }}** {{ breakingChange.value }}\
{% endfor %}\
{% endif %}\
{% endfor %}
{% endfor %}\n
"""
trim = true
footer = ""

[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
{ message = "^feat", group = "🚀 Features"},
{ message = "^fix", group = "🐛 Bug Fixes"},
{ message = "^docs", group = "📝 Documentation"},
{ message = "^perf", group = "🏃 Performance"},
{ message = "^refactor", group = "🏠 Refactor"},
{ message = "^typings", group = "⌨️ Typings"},
{ message = "^types", group = "⌨️ Typings"},
{ message = ".*deprecated", body = ".*deprecated", group = "🚨 Deprecation"},
{ message = "^revert", skip = true},
{ message = "^style", group = "🪞 Styling"},
{ message = "^test", group = "🧪 Testing"},
{ message = "^chore", skip = true},
{ message = "^ci", skip = true},
{ message = "^build", skip = true},
{ body = ".*security", group = "🛡️ Security"},
]
filter_commits = true
tag_pattern = "@skyra/i18next-type-generator@[0-9]*"
ignore_tags = ""
topo_order = false
sort_commits = "newest"
64 changes: 64 additions & 0 deletions packages/i18next-type-generator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"name": "@skyra/i18next-type-generator",
"version": "1.0.0",
"description": "A fast utility that generates the TypeScript augmentation for i18next.",
"author": "@skyra",
"license": "Apache-2.0",
"type": "module",
"main": "dist/cli.js",
"bin": {
"i18next-type-generator": "./dist/cli.js"
},
"sideEffects": false,
"scripts": {
"build": "tsup",
"watch": "tsup --watch",
"typecheck": "tsc -p tsconfig.eslint.json",
"lint": "eslint src --ext ts --fix -c ../../package.json",
"prepack": "yarn build",
"bump": "cliff-jumper",
"check-update": "cliff-jumper --dry-run"
},
"dependencies": {
"colorette": "^2.0.20",
"commander": "^11.0.0",
"tslib": "^2.6.0"
},
"optionalDependencies": {
"prettier": "^3.0.0"
},
"devDependencies": {
"@favware/cliff-jumper": "^2.1.1",
"@types/node": "^18.17.0",
"i18next": "^23.2.11",
"tsup": "^7.1.0",
"typescript": "^5.1.6"
},
"repository": {
"type": "git",
"url": "git+https://github.com/skyra-project/archid-components.git",
"directory": "packages/i18next-type-generator"
},
"files": [
"dist/"
],
"engines": {
"node": ">=16.9.0",
"npm": ">=8.0.0"
},
"keywords": [
"discord",
"api",
"http",
"skyra",
"typescript",
"ts",
"yarn"
],
"bugs": {
"url": "https://github.com/skyra-project/archid-components/issues"
},
"publishConfig": {
"access": "public"
}
}
29 changes: 29 additions & 0 deletions packages/i18next-type-generator/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <value>', '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);
84 changes: 84 additions & 0 deletions packages/i18next-type-generator/src/generate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions packages/i18next-type-generator/src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./",
"outDir": "../dist",
"target": "ES2021",
"moduleResolution": "Node16"
},
"include": ["."]
}
8 changes: 8 additions & 0 deletions packages/i18next-type-generator/tsconfig.eslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.eslint.json",
"include": ["src", "tsup.config.ts"],
"compilerOptions": {
"module": "Node16",
"moduleResolution": "node16"
}
}
3 changes: 3 additions & 0 deletions packages/i18next-type-generator/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createTsupConfig } from '../../scripts/tsup.config.js';

export default createTsupConfig({ entry: ['src/cli.ts'], format: ['esm'], target: 'es2022' });
2 changes: 2 additions & 0 deletions scripts/clean.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -21,6 +22,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),
Expand Down
5 changes: 3 additions & 2 deletions scripts/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const defaultConfigOptions: ConfigOptions = {

export const createTsupConfig = ({
globalName = undefined,
entry = ['src/index.ts'],
format = ['esm', 'cjs'],
target = 'es2021',
sourcemap = true,
Expand All @@ -32,7 +33,7 @@ export const createTsupConfig = ({
defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
entry,
format,
minify: false,
skipNodeModulesBundle: true,
Expand All @@ -45,4 +46,4 @@ export const createTsupConfig = ({
esbuildOptions
});

type ConfigOptions = Pick<Options, 'esbuildOptions' | 'esbuildPlugins' | 'sourcemap' | 'target' | 'format' | 'globalName'>;
type ConfigOptions = Pick<Options, 'esbuildOptions' | 'esbuildPlugins' | 'sourcemap' | 'target' | 'format' | 'globalName' | 'entry'>;
Loading

0 comments on commit 8e3c840

Please sign in to comment.