Skip to content

Commit 8e3c840

Browse files
committed
feat: add @skyra/i18next-type-generator
1 parent ad573b4 commit 8e3c840

File tree

15 files changed

+348
-2
lines changed

15 files changed

+348
-2
lines changed

.npm-deprecaterc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package:
44
- '@skyra/http-framework'
55
- '@skyra/http-framework-i18n'
66
- '@skyra/i18next-backend'
7+
- '@skyra/i18next-type-generator'
78
- '@skyra/logger'
89
- '@skyra/shared-http-pieces'
910
- '@skyra/start-banner'

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
[![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)
1616
[![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)
1717
[![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)
18+
[![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)
1819
[![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)
1920
[![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)
2021
[![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)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
name: i18next-type-generator
2+
org: skyra
3+
install: true
4+
packagePath: packages/i18next-type-generator
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# `@skyra/i18next-type-generator`
2+
3+
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.
4+
5+
## Usage
6+
7+
```bash
8+
$ i18next-type-generator [options] [source] [destination]
9+
10+
# Arguments:
11+
# source The directory to generate types from (default: "./src/locales/en-US/")
12+
# destination The directory to generate types to (default: "./src/@types/i18next.d.ts")
13+
#
14+
# Options:
15+
# -V, --version output the version number
16+
# -v, --verbose Verbose output
17+
# --no-prettier Disable prettier
18+
# -h, --help display help for command
19+
```
20+
21+
This CLI tool generates a `.d.ts` file with the following structure:
22+
23+
```typescript
24+
// This file is automatically generated, do not edit it.
25+
import 'i18next';
26+
27+
declare module 'i18next' {
28+
interface CustomTypeOptions {
29+
resources: {
30+
'commands/choice': {
31+
name: 'choice';
32+
description: 'Get a random value from a set of choices';
33+
// ...
34+
};
35+
// ...
36+
};
37+
}
38+
}
39+
```
40+
41+
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.
42+
43+
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.
44+
45+
> **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.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
[changelog]
2+
header = """
3+
# Changelog
4+
5+
All notable changes to this project will be documented in this file.\n
6+
"""
7+
body = """
8+
{% if version %}\
9+
# [{{ version | trim_start_matches(pat="v") }}]\
10+
{% if previous %}\
11+
{% if previous.version %}\
12+
(https://github.com/skyra-project/archid-components/compare/{{ previous.version }}...{{ version }})\
13+
{% else %}\
14+
(https://github.com/skyra-project/archid-components/tree/{{ version }})\
15+
{% endif %}\
16+
{% endif %} \
17+
- ({{ timestamp | date(format="%Y-%m-%d") }})
18+
{% else %}\
19+
# [unreleased]
20+
{% endif %}\
21+
{% for group, commits in commits | group_by(attribute="group") %}
22+
## {{ group | upper_first }}
23+
{% for commit in commits %}
24+
- {% if commit.scope %}\
25+
**{{commit.scope}}:** \
26+
{% endif %}\
27+
{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/skyra-project/archid-components/commit/{{ commit.id }}))\
28+
{% if commit.breaking %}\
29+
{% for breakingChange in commit.footers %}\
30+
\n{% raw %} {% endraw %}- 💥 **{{ breakingChange.token }}{{ breakingChange.separator }}** {{ breakingChange.value }}\
31+
{% endfor %}\
32+
{% endif %}\
33+
{% endfor %}
34+
{% endfor %}\n
35+
"""
36+
trim = true
37+
footer = ""
38+
39+
[git]
40+
conventional_commits = true
41+
filter_unconventional = true
42+
commit_parsers = [
43+
{ message = "^feat", group = "🚀 Features"},
44+
{ message = "^fix", group = "🐛 Bug Fixes"},
45+
{ message = "^docs", group = "📝 Documentation"},
46+
{ message = "^perf", group = "🏃 Performance"},
47+
{ message = "^refactor", group = "🏠 Refactor"},
48+
{ message = "^typings", group = "⌨️ Typings"},
49+
{ message = "^types", group = "⌨️ Typings"},
50+
{ message = ".*deprecated", body = ".*deprecated", group = "🚨 Deprecation"},
51+
{ message = "^revert", skip = true},
52+
{ message = "^style", group = "🪞 Styling"},
53+
{ message = "^test", group = "🧪 Testing"},
54+
{ message = "^chore", skip = true},
55+
{ message = "^ci", skip = true},
56+
{ message = "^build", skip = true},
57+
{ body = ".*security", group = "🛡️ Security"},
58+
]
59+
filter_commits = true
60+
tag_pattern = "@skyra/i18next-type-generator@[0-9]*"
61+
ignore_tags = ""
62+
topo_order = false
63+
sort_commits = "newest"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"name": "@skyra/i18next-type-generator",
3+
"version": "1.0.0",
4+
"description": "A fast utility that generates the TypeScript augmentation for i18next.",
5+
"author": "@skyra",
6+
"license": "Apache-2.0",
7+
"type": "module",
8+
"main": "dist/cli.js",
9+
"bin": {
10+
"i18next-type-generator": "./dist/cli.js"
11+
},
12+
"sideEffects": false,
13+
"scripts": {
14+
"build": "tsup",
15+
"watch": "tsup --watch",
16+
"typecheck": "tsc -p tsconfig.eslint.json",
17+
"lint": "eslint src --ext ts --fix -c ../../package.json",
18+
"prepack": "yarn build",
19+
"bump": "cliff-jumper",
20+
"check-update": "cliff-jumper --dry-run"
21+
},
22+
"dependencies": {
23+
"colorette": "^2.0.20",
24+
"commander": "^11.0.0",
25+
"tslib": "^2.6.0"
26+
},
27+
"optionalDependencies": {
28+
"prettier": "^3.0.0"
29+
},
30+
"devDependencies": {
31+
"@favware/cliff-jumper": "^2.1.1",
32+
"@types/node": "^18.17.0",
33+
"i18next": "^23.2.11",
34+
"tsup": "^7.1.0",
35+
"typescript": "^5.1.6"
36+
},
37+
"repository": {
38+
"type": "git",
39+
"url": "git+https://github.com/skyra-project/archid-components.git",
40+
"directory": "packages/i18next-type-generator"
41+
},
42+
"files": [
43+
"dist/"
44+
],
45+
"engines": {
46+
"node": ">=16.9.0",
47+
"npm": ">=8.0.0"
48+
},
49+
"keywords": [
50+
"discord",
51+
"api",
52+
"http",
53+
"skyra",
54+
"typescript",
55+
"ts",
56+
"yarn"
57+
],
58+
"bugs": {
59+
"url": "https://github.com/skyra-project/archid-components/issues"
60+
},
61+
"publishConfig": {
62+
"access": "public"
63+
}
64+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env node
2+
3+
import { Command, InvalidArgumentError } from 'commander';
4+
import { readFile } from 'node:fs/promises';
5+
import { generate } from './generate.js';
6+
7+
const cli = new Command();
8+
9+
const packageFile = new URL('../package.json', import.meta.url);
10+
const packageJson = JSON.parse(await readFile(packageFile, 'utf-8'));
11+
12+
const indentationParser = (value: string) => {
13+
if (value === 'tabs') return '\t';
14+
15+
const parsed = Number(value);
16+
if (Number.isNaN(parsed)) throw new InvalidArgumentError('The indentation must be a number or "tabs"');
17+
return ' '.repeat(parsed);
18+
};
19+
20+
cli.name('i18next-type-generator') //
21+
.version(packageJson.version)
22+
.argument('[source]', 'The directory to generate types from', './src/locales/en-US/')
23+
.argument('[destination]', 'The directory to generate types to', './src/@types/i18next.d.ts')
24+
.option('-i, --indentation <value>', 'The indentation to use', indentationParser, '\t')
25+
.option('-v, --verbose', 'Verbose output')
26+
.option('--no-prettier', 'Disable prettier')
27+
.action((...args) => generate(args, cli.opts()));
28+
29+
cli.parse(process.argv);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { gray, green, italic } from 'colorette';
2+
import { mkdir, opendir, readFile, writeFile } from 'node:fs/promises';
3+
import { dirname, join, resolve } from 'node:path';
4+
import { inspect } from 'node:util';
5+
6+
const ci = 'CI' in process.env && process.env.CI !== 'false';
7+
8+
const FileIcon = ci ? '📄' : '\uE628';
9+
const DirectoryIcon = ci ? '📂' : '\uF413';
10+
11+
const i1 = '\t';
12+
const i2 = i1.repeat(2);
13+
const i3 = i1.repeat(3);
14+
15+
/**
16+
* Recursively walk through the directory and generate the types.
17+
* @param path The path to walk through.
18+
* @param namespace The namespace to use.
19+
*/
20+
async function recurse(lines: string[], path: string, namespace: string, options: GenerateOptions) {
21+
if (options.verbose) console.log(gray(`Reading directory ${DirectoryIcon} ${green(path)}...`));
22+
23+
for await (const dirent of await opendir(path)) {
24+
const file = join(path, dirent.name);
25+
if (dirent.isFile()) {
26+
if (!dirent.name.endsWith('.json')) continue;
27+
if (options.verbose) console.log(gray(`Processing ${FileIcon} ${green(file)}...`));
28+
29+
const name = dirent.name.slice(0, -5);
30+
const key = namespace ? `'${namespace}/${name}'` : name;
31+
const data = JSON.stringify(JSON.parse(await readFile(file, 'utf8')), undefined, i1);
32+
lines.push(`${i3}${key}: ${data.replaceAll('\n', `\n${i3}`)};`);
33+
} else if (dirent.isDirectory()) {
34+
await recurse(lines, file, namespace ? `${namespace}/${dirent.name}` : dirent.name, options);
35+
}
36+
}
37+
}
38+
39+
export async function generate([source, destination]: string[], options: GenerateOptions) {
40+
const sourceDirectory = resolve(source);
41+
const destinationFile = resolve(destination);
42+
43+
if (options.verbose) {
44+
const lines = [
45+
`Source: ${DirectoryIcon} ${green(sourceDirectory)}...`,
46+
`Output: ${FileIcon} ${green(destinationFile)}...`,
47+
'',
48+
'Options:',
49+
` - Verbose: ${green(options.verbose ? 'yes' : 'no')}`
50+
];
51+
console.log(italic(gray(lines.join('\n'))));
52+
}
53+
54+
const lines = [
55+
'// This file is automatically generated, do not edit it.',
56+
"import 'i18next';",
57+
'',
58+
"declare module 'i18next' {",
59+
`${i1}interface CustomTypeOptions {`,
60+
`${i2}resources: {`
61+
];
62+
63+
await recurse(lines, sourceDirectory, '', options);
64+
lines.push(`${i2}};`, `${i1}}`, '}', '');
65+
66+
let generatedSource = lines.join('\n');
67+
if (options.prettier) {
68+
if (options.verbose) console.log(gray(`Loading prettier...`));
69+
70+
const { resolveConfig, format } = await import('prettier');
71+
const config = await resolveConfig(destinationFile);
72+
if (options.verbose) console.log(gray(`Formatting with prettier config: ${inspect(config, { colors: true })}`));
73+
74+
generatedSource = await format(generatedSource, { ...config, filepath: destinationFile });
75+
}
76+
77+
await mkdir(dirname(destinationFile), { recursive: true });
78+
await writeFile(destinationFile, generatedSource, 'utf8');
79+
}
80+
81+
interface GenerateOptions {
82+
verbose: boolean;
83+
prettier: boolean;
84+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"rootDir": "./",
5+
"outDir": "../dist",
6+
"target": "ES2021",
7+
"moduleResolution": "Node16"
8+
},
9+
"include": ["."]
10+
}

0 commit comments

Comments
 (0)