Skip to content

Commit 89431ae

Browse files
committed
feat: enhance i18n
- Add functional interpolation - Add a dedicated `.jsx()` function to handle JSX - Add docs - Discard unnamed interpolation
1 parent b460f16 commit 89431ae

File tree

5 files changed

+213
-49
lines changed

5 files changed

+213
-49
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ ZOOT Plus 前端!
66

77
- ~~后端接口文档~~ (暂无,请参考 [zoot-plus-client](https://github.com/ZOOT-Plus/zoot-plus-client-ts) 的 TS 类型,或者从后端 [Actions](https://github.com/ZOOT-Plus/ZootPlusBackend/actions/workflows/openapi.yml) 的 Artifacts 里下载最新的 OpenAPI 文档)
88
- 作业格式:[战斗流程协议](https://maa.plus/docs/zh-cn/protocol/copilot-schema.html)
9+
- i18n:[i18n/README.md](src/i18n/README.md)
910

1011
更新 zoot-plus-client 时,需要在 [Tags](https://github.com/ZOOT-Plus/zoot-plus-client-ts/tags) 中复制版本号,然后替换掉 `package.json` 中的 `maa-copilot-client` 版本号,再运行 `yarn` 安装依赖
1112

src/i18n/README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
## `translations.json` syntax
2+
3+
```jsonc
4+
{
5+
"pages": {
6+
"index": {
7+
// plain text
8+
"title": {
9+
"cn": "首页",
10+
"en": "Home",
11+
},
12+
13+
// interpolation
14+
"error": {
15+
"cn": "错误: {{error}}",
16+
"en": "Error: {{error}}",
17+
},
18+
19+
// pluralization
20+
"posts": {
21+
"cn": "{{user}} 有 {{count}} 篇文章",
22+
"en": {
23+
"1": "{{user}} has {{count}} post",
24+
"other": "{{user}} has {{count}} posts",
25+
},
26+
},
27+
28+
// functional interpolation
29+
"docs": {
30+
"cn": "请阅读我们的{{link(文档)}}",
31+
"en": "Please read our {{link(documentations)}}",
32+
},
33+
},
34+
},
35+
}
36+
```
37+
38+
## Usage
39+
40+
### 1. Use translations in components via `useTranslation` hook
41+
42+
This is the most common way. Using `useTranslation` makes the component re-render when the language changes, so that the messages are always up-to-date.
43+
44+
```tsx
45+
function SomeComponent() {
46+
const t = useTranslation()
47+
48+
// plain text messages are just plain strings
49+
t.pages.index.title
50+
51+
// other kinds of messages are converted to functions that return a string
52+
t.pages.index.error({ error: '404' })
53+
54+
// a pluralized message generates an extra `count` key to determine the plural form
55+
t.pages.index.posts({ count: 1, user: userName })
56+
57+
// to use JSX, call the `.jsx` function
58+
t.pages.index.posts.jsx({ count: 1, user: <b>{userName}</b> })
59+
60+
// functional interpolation keys are only available in the `.jsx` function
61+
t.pages.index.docs.jsx({ link: (s) => <a href="/docs">{s}</a> })
62+
}
63+
```
64+
65+
### 2. Use translations outside components via the `i18n` object.
66+
67+
The `i18n` object serves messages of the current language. It has the same structure as the return value of `useTranslation`.
68+
69+
Note that this won't make the component re-render when the language changes, so it's suitable for static messages or messages that don't need to be updated frequently.
70+
71+
```ts
72+
function getMessages() {
73+
return {
74+
title: i18n.pages.index.title,
75+
error: i18n.pages.index.error({ error: '404' }),
76+
}
77+
}
78+
79+
function SomeComponent() {
80+
getMessages()
81+
}
82+
```
83+
84+
### 3. Use translations outside components via the `i18nDefer` object.
85+
86+
It differs from `i18n` in that all messages, including plain text ones, are converted to functions, so that we can call them later to get up-to-date messages for the current language.
87+
88+
```ts
89+
const title = i18nDefer.pages.index.title
90+
const error = i18nDefer.pages.index.error
91+
92+
function Component() {
93+
title()
94+
error({ error: '404' })
95+
}
96+
```
97+
98+
## Workflow
99+
100+
There is a special section in `translations.json` called `essentials`, which will be statically bundled into the app for displaying messages before the overall translations are loaded. Specifically, it's used to render the error state when the translations fail to load.
101+
102+
`scripts/generate-translations.ts` is a Vite plugin that will split `translations.json` into a `.ts` file for each language, and an `essentials.ts` containing the `essentials` section of every language. The reason to use `.ts` is that it's strongly typed, allowing us to use the type information to generate strictly typed interpolation functions, which is impossible with `.json`.
103+
104+
A bonus by using `.ts` is that TypeScript can now trace the messages' references, so we can use IDE's "Go to definition" to inspect the referenced messages, or use "Find all references" to find where a message is referenced in the codebase.
105+
106+
During development, the plugin watches `translations.json`. When this file is modified, the plugin will re-generate the split files to trigger a hot reload.

src/i18n/i18n.ts

Lines changed: 98 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { atomWithStorage } from 'jotai/utils'
33
import { get, isObject, isString } from 'lodash-es'
44
import mitt from 'mitt'
55
import { Fragment, ReactElement, ReactNode, createElement } from 'react'
6-
import { ValueOf } from 'type-fest'
76

7+
import { preserveLineBreaks } from '../utils/react'
88
import ESSENTIALS from './generated/essentials'
99

1010
export const languages = ['cn', 'en'] as const
@@ -23,6 +23,26 @@ type I18NEssentials = MakeTranslations<(typeof ESSENTIALS)[Language]>
2323

2424
type MakeTranslations<T> = MakeEndpoints<ParseValue<T>>
2525

26+
// 1. First pass: Convert a tree of messages to a tree of strings and interpolation keys
27+
//
28+
// - If a value is a plain string, replace it with `string`
29+
// - If a value is an interpolation string, extract interpolation keys and replace it with the keys
30+
// - If a value is a plural object, replace it with object['other'] as an interpolation string with an additional `count` key
31+
//
32+
// During this pass, we preserve distributivity to properly handle cases where a message
33+
// is of different kinds in different languages. In the following example, the "cn"
34+
// message is a plain string, while the "en" message is a plural object:
35+
//
36+
// "error_count": {
37+
// "cn": "{{count}} 个错误",
38+
// "en": {
39+
// "1": "{{count}} error",
40+
// "other": "{{count}} errors"
41+
// }
42+
// }
43+
//
44+
// Ref: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
45+
2646
type ParseValue<T> = T extends string
2747
? ParseMessage<T, []>
2848
: T extends PluralObject
@@ -31,41 +51,56 @@ type ParseValue<T> = T extends string
3151

3252
type ParseMessage<
3353
T extends string,
34-
InitialKeys extends unknown[],
54+
InitialKeys extends string[],
3555
Keys = InterpolationKeys<T, InitialKeys>,
3656
> = Keys extends [] ? string : Keys
3757

3858
type InterpolationKeys<
3959
Str,
40-
Keys extends unknown[],
60+
Keys extends string[],
4161
> = Str extends `${string}{{${infer Key}}}${infer End}`
42-
? InterpolationKeys<End, [...Keys, Key extends '' ? UnnamedKey : Key]>
62+
? InterpolationKeys<End, [...Keys, Key]>
4363
: Keys
4464

4565
type PluralObject = Record<`${number}` | 'other', string>
4666

67+
// 2. Second pass: Convert a tree of strings and interpolation keys to a tree of strings and functions (endpoints)
68+
//
69+
// - If a value is a string, do nothing
70+
// - If a value is interpolation keys, convert it to a function
71+
//
72+
// During this pass, we *prevent* distributivity and merge the unions to keep the endpoint function types
73+
// clean and human-readable.
74+
//
75+
// Tricks to prevent distributivity:
76+
// - For a conditional type, either invert the condition if possible, e.g. `string extends T`,
77+
// or wrap the type parameter in an array, e.g. `[T] extends [string]`.
78+
// - For a mapped type, make it non-homomorphic[1] by extracting `keyof T` to a new type parameter `K`.
79+
//
80+
// [1]: https://stackoverflow.com/a/59791889/13237325
81+
4782
type MakeEndpoints<T, K extends keyof T = keyof T> = string extends T
4883
? T
49-
: [T] extends [unknown[]]
50-
? Endpoint<T>
84+
: [T] extends [string[]]
85+
? Interpolation<T> & {}
5186
: { [P in K]: MakeEndpoints<T[P]> }
5287

53-
type Endpoint<Keys extends unknown[]> = Keys[number] extends UnnamedKey
54-
? UnnamedInterpolation<{ [K in keyof Keys]: ReactNode }>
55-
: Interpolation<{ [K in Extract<Keys[number], string>]: ReactNode }>
56-
57-
type Interpolation<Arg> = <T extends Arg>(
58-
...args: [T]
59-
) => InterpolationResult<ValueOf<T>>
60-
61-
type UnnamedInterpolation<Arg extends unknown[]> = <T extends Arg>(
62-
...args: T
63-
) => InterpolationResult<T[number]>
64-
65-
type InterpolationResult<T> = T extends string | number ? string : ReactElement
88+
type Interpolation<
89+
Keys extends string[],
90+
KeyMapping = {
91+
[K in Keys[number]]: K extends `${infer Name}(${string})` ? Name : K
92+
},
93+
> = ((options: {
94+
[K in keyof KeyMapping as K extends KeyMapping[K] ? K : never]: Primitive
95+
}) => string) & {
96+
jsx: (options: {
97+
[K in keyof KeyMapping as KeyMapping[K] & string]: KeyMapping[K] extends K
98+
? ReactNode
99+
: (arg?: string) => ReactNode
100+
}) => ReactElement
101+
}
66102

67-
declare const unnamedKey: unique symbol
68-
type UnnamedKey = typeof unnamedKey
103+
type Primitive = string | number | boolean | null | undefined
69104

70105
export const allEssentials = Object.fromEntries(
71106
Object.entries(ESSENTIALS).map(([language, data]) => [
@@ -198,6 +233,7 @@ function setupTranslations({ language, data }: RawTranslations) {
198233
}
199234

200235
const interpolationRegex = /{{([^}]*)}}/
236+
const functionalInterpolationKeyRegex = /(.*?)\((.*?)\)/
201237

202238
const convert = (path: string, value: unknown) => {
203239
const converted = doConvert(path, value)
@@ -229,15 +265,19 @@ function setupTranslations({ language, data }: RawTranslations) {
229265

230266
// as of now, value is either an interpolatable string or a plural object
231267

232-
return (...args: unknown[]) => {
268+
const interpolate = (
269+
options: Record<
270+
string,
271+
Primitive | ReactNode | ((arg?: string) => ReactNode)
272+
>,
273+
jsx: boolean,
274+
) => {
233275
try {
234276
let message: string
235277

236278
if (isPlural) {
237279
const pluralObject = value as PluralObject
238-
const count = isObject(args[0])
239-
? (args[0] as Record<string, unknown>).count
240-
: undefined
280+
const count = options.count
241281
if (typeof count === 'number') {
242282
message = pluralObject[String(count)] ?? pluralObject.other
243283
} else {
@@ -251,41 +291,52 @@ function setupTranslations({ language, data }: RawTranslations) {
251291
if (segments.length === 1) {
252292
return message
253293
}
254-
let hasJsx = false
255-
const translated = segments.map((segment, index) => {
294+
295+
const interpolated = segments.map((segment, index) => {
256296
if (index % 2 === 0) {
257-
return segment
258-
}
259-
if (!segment) {
260-
const valueIndex = (index - 1) / 2
261-
const value = args[valueIndex]
262-
if (!value) {
263-
return ''
264-
}
265-
if (typeof value !== 'string' && typeof value !== 'number') {
266-
hasJsx = true
297+
if (segment && segment.includes('\n')) {
298+
return preserveLineBreaks(segment)
267299
}
268-
return value
300+
return segment
269301
}
270302

271-
const value = args[0]?.[segment]
272-
if (!value) {
273-
return ''
303+
if (Object.prototype.hasOwnProperty.call(options, segment)) {
304+
return options[segment] as Primitive | ReactNode
274305
}
275-
if (typeof value !== 'string' && typeof value !== 'number') {
276-
hasJsx = true
306+
307+
const match = segment.match(functionalInterpolationKeyRegex)
308+
if (match) {
309+
const key = match[1]
310+
const arg = match[2]
311+
if (Object.prototype.hasOwnProperty.call(options, key)) {
312+
if (typeof options[key] === 'function') {
313+
return options[key](arg)
314+
}
315+
return options[key]
316+
}
277317
}
278-
return value
318+
319+
return ''
279320
})
280-
if (hasJsx) {
281-
return createElement(Fragment, {}, ...translated)
321+
if (jsx) {
322+
return createElement(Fragment, {}, ...interpolated)
282323
}
283-
return translated.join('')
324+
return interpolated.join('')
284325
} catch (e) {
285326
console.error('Error in translation:', path, e)
286327
return path
287328
}
288329
}
330+
331+
const interpolationEndpoint = (
332+
options: Record<string, Primitive>,
333+
): string => interpolate(options, false) as string
334+
335+
interpolationEndpoint.jsx = (
336+
options: Record<string, ReactNode | ((arg?: string) => ReactNode)>,
337+
): ReactElement => interpolate(options, true) as ReactElement
338+
339+
return interpolationEndpoint
289340
}
290341

291342
return convert('', data)

src/models/operator.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,6 @@ export function getSkillUsageAltTitle(
286286
) {
287287
if (skillUsage === CopilotDocV1.SkillUsageType.ReadyToUseTimes) {
288288
return i18n.models.operator.skill_usage.ready_to_use_times.alt_format({
289-
count: skillTimes ?? 1,
290289
times: skillTimes ?? 1,
291290
})
292291
}

src/utils/react.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
useRef,
77
} from 'react'
88

9-
export function joinJSX(elements: ReactNode[], separator: ReactNode) {
9+
export function joinJSX(
10+
elements: ReactNode[],
11+
separator: ReactNode,
12+
): ReactNode[] {
1013
return elements.reduce((acc: ReactNode[], element, index) => {
1114
if (index === 0) return [element]
1215
return [
@@ -17,6 +20,10 @@ export function joinJSX(elements: ReactNode[], separator: ReactNode) {
1720
}, [])
1821
}
1922

23+
export function preserveLineBreaks(text: string) {
24+
return joinJSX(text.split('\n'), <br />)
25+
}
26+
2027
// The useEvent API has not yet been added to React,
2128
// so this is a temporary shim to make this sandbox work.
2229
// You're not expected to write code like this yourself.

0 commit comments

Comments
 (0)