@kokimoki/app 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/core/kokimoki-client.d.ts +75 -15
- package/dist/core/kokimoki-client.js +137 -22
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/kokimoki.min.d.ts +322 -2
- package/dist/kokimoki.min.js +1884 -72
- package/dist/kokimoki.min.js.map +1 -1
- package/dist/llms.txt +6 -0
- package/dist/protocol/ws-message/reader.d.ts +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/kokimoki-ai.d.ts +185 -122
- package/dist/services/kokimoki-ai.js +201 -109
- package/dist/services/kokimoki-i18n.d.ts +259 -0
- package/dist/services/kokimoki-i18n.js +325 -0
- package/dist/stores/kokimoki-local-store.d.ts +1 -1
- package/dist/types/common.d.ts +9 -0
- package/dist/types/env.d.ts +36 -0
- package/dist/types/env.js +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/kokimoki-client.d.ts +31 -0
- package/dist/utils/kokimoki-client.js +38 -0
- package/dist/utils/kokimoki-dev.d.ts +30 -0
- package/dist/utils/kokimoki-dev.js +75 -0
- package/dist/utils/kokimoki-env.d.ts +20 -0
- package/dist/utils/kokimoki-env.js +30 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/kokimoki-ai.instructions.md +316 -0
- package/docs/kokimoki-dynamic-stores.instructions.md +439 -0
- package/docs/kokimoki-i18n.instructions.md +285 -0
- package/docs/kokimoki-leaderboard.instructions.md +189 -0
- package/docs/kokimoki-sdk.instructions.md +221 -0
- package/docs/kokimoki-storage.instructions.md +162 -0
- package/llms.txt +43 -0
- package/package.json +9 -13
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Dynamic stores for room-based state management with Kokimoki SDK"
|
|
3
|
+
applyTo: "**/*.ts,**/*.tsx"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Dynamic Stores
|
|
7
|
+
|
|
8
|
+
Dynamic stores enable creation of isolated, room-based state management. This is useful for scenarios where subsets of players need to share state without affecting the global store.
|
|
9
|
+
|
|
10
|
+
For core SDK concepts, see [Kokimoki SDK](./kokimoki-sdk.instructions.md).
|
|
11
|
+
|
|
12
|
+
## When to Use Dynamic Stores
|
|
13
|
+
|
|
14
|
+
**Use for:**
|
|
15
|
+
|
|
16
|
+
- **Room-based isolation**: Chat rooms, team spaces, breakout rooms
|
|
17
|
+
- **Player grouping**: Teams, squads, parties that share private state
|
|
18
|
+
- **Multi-instance features**: Multiple parallel games or activities
|
|
19
|
+
- **Scoped collaboration**: Players working together on specific tasks
|
|
20
|
+
|
|
21
|
+
**Do NOT use for:**
|
|
22
|
+
|
|
23
|
+
- Global game state (use `kmClient.store()` instead)
|
|
24
|
+
- Player-specific data (use `kmClient.localStore()` instead)
|
|
25
|
+
- Single shared space for all players
|
|
26
|
+
|
|
27
|
+
## General Best Practices
|
|
28
|
+
|
|
29
|
+
- **Unique room names**: Use descriptive prefixes (e.g., `"chat-room-"`, `"team-"`)
|
|
30
|
+
- **Key remounting**: Use `key={roomCode}` on components to force remount when switching rooms
|
|
31
|
+
- **Check isConnected**: Wait for connection before showing/updating content
|
|
32
|
+
- **Defensive state checks**: Always verify properties exist before accessing (e.g., `if (!state.players)` before using)
|
|
33
|
+
- **UseEffect guards**: Check for undefined/null in useEffect conditions that depend on dynamic store state
|
|
34
|
+
- **Cleanup on leave**: Remove player-specific data when leaving a room
|
|
35
|
+
- **Use actions**: Never inline `kmClient.transact` calls in components
|
|
36
|
+
- **Use Records, not Arrays**: Store collections as `Record<string, T>` with timestamp keys for automatic sorting and better sync performance
|
|
37
|
+
- **Atomic transactions**: Combine related state updates in single transaction when possible
|
|
38
|
+
|
|
39
|
+
## Creating a useDynamicStore Hook
|
|
40
|
+
|
|
41
|
+
Since dynamic stores require lifecycle management (join/leave), create a custom hook that wraps the store lifecycle:
|
|
42
|
+
|
|
43
|
+
**Hook implementation** (`src/hooks/useDynamicStore.ts`):
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { useState, useEffect, useRef } from "react";
|
|
47
|
+
import { getKmClient, KokimokiStore } from "@kokimoki/app";
|
|
48
|
+
|
|
49
|
+
const kmClient = getKmClient();
|
|
50
|
+
|
|
51
|
+
// Global store cache for reference counting
|
|
52
|
+
const storeCache = new Map<
|
|
53
|
+
string,
|
|
54
|
+
{ store: KokimokiStore<any>; refCount: number }
|
|
55
|
+
>();
|
|
56
|
+
|
|
57
|
+
export function useDynamicStore<T extends object>(
|
|
58
|
+
roomName: string,
|
|
59
|
+
initialState: T
|
|
60
|
+
) {
|
|
61
|
+
const [isConnecting, setIsConnecting] = useState(true);
|
|
62
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
63
|
+
const storeRef = useRef<KokimokiStore<T> | null>(null);
|
|
64
|
+
|
|
65
|
+
// Get or create the store
|
|
66
|
+
if (!storeRef.current) {
|
|
67
|
+
const cached = storeCache.get(roomName);
|
|
68
|
+
if (cached) {
|
|
69
|
+
storeRef.current = cached.store;
|
|
70
|
+
cached.refCount++;
|
|
71
|
+
} else {
|
|
72
|
+
const store = kmClient.store<T>(roomName, initialState);
|
|
73
|
+
storeRef.current = store;
|
|
74
|
+
storeCache.set(roomName, { store, refCount: 1 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const store = storeRef.current!;
|
|
80
|
+
let mounted = true;
|
|
81
|
+
|
|
82
|
+
const joinStore = async () => {
|
|
83
|
+
try {
|
|
84
|
+
await kmClient.join(store);
|
|
85
|
+
if (mounted) {
|
|
86
|
+
setIsConnected(true);
|
|
87
|
+
setIsConnecting(false);
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`Failed to join store "${roomName}":`, error);
|
|
91
|
+
if (mounted) {
|
|
92
|
+
setIsConnecting(false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
joinStore();
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
mounted = false;
|
|
101
|
+
const cached = storeCache.get(roomName);
|
|
102
|
+
if (cached) {
|
|
103
|
+
cached.refCount--;
|
|
104
|
+
if (cached.refCount <= 0) {
|
|
105
|
+
kmClient.leave(store);
|
|
106
|
+
storeCache.delete(roomName);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}, [roomName]);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
store: storeRef.current!,
|
|
114
|
+
isConnected,
|
|
115
|
+
isConnecting,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Hook API
|
|
121
|
+
|
|
122
|
+
**Parameters:**
|
|
123
|
+
|
|
124
|
+
- **roomName**: `string` - Unique identifier for the store (e.g., `"chat-room-abc123"`)
|
|
125
|
+
- **initialState**: `T` - Default state structure with initial values
|
|
126
|
+
|
|
127
|
+
**Return Values:**
|
|
128
|
+
|
|
129
|
+
- **store**: `KokimokiStore<T>` - The Kokimoki store instance
|
|
130
|
+
- **isConnected**: `boolean` - Whether successfully joined the store
|
|
131
|
+
- **isConnecting**: `boolean` - Whether currently joining the store
|
|
132
|
+
|
|
133
|
+
## Basic Usage
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import { useSnapshot } from "valtio";
|
|
137
|
+
import { useDynamicStore } from "@/hooks/useDynamicStore";
|
|
138
|
+
|
|
139
|
+
interface RoomState {
|
|
140
|
+
messages: Record<string, string>; // key: timestamp, value: message
|
|
141
|
+
count: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const initialState: RoomState = {
|
|
145
|
+
messages: {},
|
|
146
|
+
count: 0,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const MyComponent = ({ roomCode }: { roomCode: string }) => {
|
|
150
|
+
const { store, isConnected } = useDynamicStore<RoomState>(
|
|
151
|
+
`my-room-${roomCode}`,
|
|
152
|
+
initialState
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const roomState = useSnapshot(store.proxy);
|
|
156
|
+
|
|
157
|
+
// Update state
|
|
158
|
+
const addMessage = async (message: string) => {
|
|
159
|
+
await kmClient.transact([store], ([state]) => {
|
|
160
|
+
const timestamp = kmClient.serverTimestamp().toString();
|
|
161
|
+
state.messages[timestamp] = message;
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (!isConnected) {
|
|
166
|
+
return <div>Connecting...</div>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return <div>Messages: {Object.keys(roomState.messages).length}</div>;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Use key to remount component on roomCode change
|
|
173
|
+
<MyComponent key={roomCode} roomCode={roomCode} />;
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Common Patterns
|
|
177
|
+
|
|
178
|
+
### Example: Team-based State
|
|
179
|
+
|
|
180
|
+
Players can be grouped into teams with private team state:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
interface TeamState {
|
|
184
|
+
teamName: string;
|
|
185
|
+
players: Record<string, { name: string; role: string }>;
|
|
186
|
+
score: number;
|
|
187
|
+
strategy: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const TeamView = ({ teamId }: { teamId: string }) => {
|
|
191
|
+
const { name } = useSnapshot(playerStore.proxy);
|
|
192
|
+
const { store: teamStore, isConnected } = useDynamicStore<TeamState>(
|
|
193
|
+
`team-${teamId}`,
|
|
194
|
+
{
|
|
195
|
+
teamName: `Team ${teamId}`,
|
|
196
|
+
players: {},
|
|
197
|
+
score: 0,
|
|
198
|
+
strategy: "",
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const teamState = useSnapshot(teamStore.proxy);
|
|
203
|
+
|
|
204
|
+
// Join team on connect
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (isConnected) {
|
|
207
|
+
kmClient.transact([teamStore], ([state]) => {
|
|
208
|
+
state.players[kmClient.id] = { name, role: "member" };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}, [isConnected]);
|
|
212
|
+
|
|
213
|
+
// Update team strategy
|
|
214
|
+
const updateStrategy = async (strategy: string) => {
|
|
215
|
+
await kmClient.transact([teamStore], ([state]) => {
|
|
216
|
+
state.strategy = strategy;
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div>
|
|
222
|
+
<h2>{teamState.teamName}</h2>
|
|
223
|
+
<p>Score: {teamState.score}</p>
|
|
224
|
+
<p>Players: {Object.keys(teamState.players || {}).length}</p>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Example: Chat Rooms
|
|
231
|
+
|
|
232
|
+
Multiple independent chat rooms:
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
interface ChatMessage {
|
|
236
|
+
clientId: string;
|
|
237
|
+
playerName: string;
|
|
238
|
+
text: string;
|
|
239
|
+
timestamp: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface ChatState {
|
|
243
|
+
messages: Record<string, ChatMessage>; // key: timestamp as string
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const ChatRoom = ({ roomCode }: { roomCode: string }) => {
|
|
247
|
+
const { store, isConnected } = useDynamicStore<ChatState>(
|
|
248
|
+
`chat-${roomCode}`,
|
|
249
|
+
{ messages: {} }
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const { messages } = useSnapshot(store.proxy);
|
|
253
|
+
|
|
254
|
+
const sendMessage = async (playerName: string, text: string) => {
|
|
255
|
+
await kmClient.transact([store], ([state]) => {
|
|
256
|
+
const timestamp = kmClient.serverTimestamp();
|
|
257
|
+
state.messages[timestamp.toString()] = {
|
|
258
|
+
clientId: kmClient.id,
|
|
259
|
+
playerName,
|
|
260
|
+
text,
|
|
261
|
+
timestamp,
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Get sorted messages
|
|
267
|
+
const sortedMessages = Object.entries(messages || {})
|
|
268
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
269
|
+
.map(([, msg]) => msg);
|
|
270
|
+
|
|
271
|
+
return <div>{sortedMessages.length} messages</div>;
|
|
272
|
+
};
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Example: Breakout Rooms
|
|
276
|
+
|
|
277
|
+
Small group discussions or activities:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
interface BreakoutRoomState {
|
|
281
|
+
topic: string;
|
|
282
|
+
participants: Record<string, { joinedAt: number }>; // key: clientId
|
|
283
|
+
responses: Record<string, string>; // key: clientId
|
|
284
|
+
completed: boolean;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const BreakoutRoom = ({ roomId }: { roomId: string }) => {
|
|
288
|
+
const { store, isConnected } = useDynamicStore<BreakoutRoomState>(
|
|
289
|
+
`breakout-${roomId}`,
|
|
290
|
+
{
|
|
291
|
+
topic: "",
|
|
292
|
+
participants: {},
|
|
293
|
+
responses: {},
|
|
294
|
+
completed: false,
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const roomState = useSnapshot(store.proxy);
|
|
299
|
+
|
|
300
|
+
// Join room on connect
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (isConnected) {
|
|
303
|
+
kmClient.transact([store], ([state]) => {
|
|
304
|
+
if (!state.participants[kmClient.id]) {
|
|
305
|
+
state.participants[kmClient.id] = {
|
|
306
|
+
joinedAt: kmClient.serverTimestamp(),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}, [isConnected]);
|
|
312
|
+
|
|
313
|
+
const submitResponse = async (response: string) => {
|
|
314
|
+
await kmClient.transact([store], ([state]) => {
|
|
315
|
+
state.responses[kmClient.id] = response;
|
|
316
|
+
});
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div>
|
|
321
|
+
<h3>{roomState.topic}</h3>
|
|
322
|
+
<p>Participants: {Object.keys(roomState.participants || {}).length}</p>
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Key Behaviors
|
|
329
|
+
|
|
330
|
+
**Store Lifecycle:**
|
|
331
|
+
|
|
332
|
+
- Stores are **created on first use** and **cached** globally
|
|
333
|
+
- Multiple components using the same `roomName` share the same store instance
|
|
334
|
+
- Reference counting ensures cleanup only when all components unmount
|
|
335
|
+
|
|
336
|
+
**State Synchronization:**
|
|
337
|
+
|
|
338
|
+
- State syncs in real-time across all clients joined to the same room
|
|
339
|
+
- Use transactions to update state atomically
|
|
340
|
+
- **CRITICAL**: Always check if state properties exist before accessing them (e.g., `if (state.players)` before `Object.keys(state.players)`)
|
|
341
|
+
- Initial state may be `undefined` during connection/sync - add defensive checks
|
|
342
|
+
|
|
343
|
+
**Connection Management:**
|
|
344
|
+
|
|
345
|
+
- Component automatically joins the store on mount
|
|
346
|
+
- Component automatically leaves the store on unmount
|
|
347
|
+
- `isConnected` indicates successful join
|
|
348
|
+
- `isConnecting` indicates join in progress
|
|
349
|
+
- **Store state may not be fully initialized immediately after `isConnected` becomes true**
|
|
350
|
+
|
|
351
|
+
## File Organization
|
|
352
|
+
|
|
353
|
+
**ALWAYS** organize dynamic store code using the standard store/actions pattern:
|
|
354
|
+
|
|
355
|
+
**State definition** (`src/state/chat-store.ts`):
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// Export message interface
|
|
359
|
+
export interface ChatMessage {
|
|
360
|
+
clientId: string;
|
|
361
|
+
playerName: string;
|
|
362
|
+
message: string;
|
|
363
|
+
timestamp: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Export state interface
|
|
367
|
+
export interface ChatState {
|
|
368
|
+
messages: Record<string, ChatMessage>; // key: timestamp as string
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Export function to generate initial state
|
|
372
|
+
export function createChatState(): ChatState {
|
|
373
|
+
return {
|
|
374
|
+
messages: {},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Actions** (`src/state/actions/chat-actions.ts`):
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
import type { KokimokiStore } from "@kokimoki/app";
|
|
383
|
+
import { getKmClient } from "@kokimoki/app";
|
|
384
|
+
import type { ChatState, ChatMessage } from "../chat-store";
|
|
385
|
+
|
|
386
|
+
const kmClient = getKmClient();
|
|
387
|
+
|
|
388
|
+
export const chatActions = {
|
|
389
|
+
async sendMessage(
|
|
390
|
+
store: KokimokiStore<ChatState>,
|
|
391
|
+
playerName: string,
|
|
392
|
+
message: string
|
|
393
|
+
) {
|
|
394
|
+
await kmClient.transact([store], ([state]) => {
|
|
395
|
+
const timestamp = kmClient.serverTimestamp();
|
|
396
|
+
const newMessage: ChatMessage = {
|
|
397
|
+
clientId: kmClient.id,
|
|
398
|
+
playerName,
|
|
399
|
+
message,
|
|
400
|
+
timestamp,
|
|
401
|
+
};
|
|
402
|
+
state.messages[timestamp.toString()] = newMessage;
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
**Usage in component** (`src/views/chat-view.tsx`):
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
import { useSnapshot } from "valtio";
|
|
412
|
+
import { useDynamicStore } from "@/hooks/useDynamicStore";
|
|
413
|
+
import { createChatState, type ChatState } from "@/state/chat-store";
|
|
414
|
+
import { chatActions } from "@/state/actions/chat-actions";
|
|
415
|
+
|
|
416
|
+
const ChatRoom = ({ roomCode }: { roomCode: string }) => {
|
|
417
|
+
const { store, isConnected } = useDynamicStore<ChatState>(
|
|
418
|
+
`chat-${roomCode}`,
|
|
419
|
+
createChatState()
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const { messages } = useSnapshot(store.proxy);
|
|
423
|
+
|
|
424
|
+
// Get sorted messages
|
|
425
|
+
const sortedMessages = Object.entries(messages || {})
|
|
426
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
427
|
+
.map(([, msg]) => msg);
|
|
428
|
+
|
|
429
|
+
const handleSend = async (message: string) => {
|
|
430
|
+
await chatActions.sendMessage(store, playerName, message);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
if (!isConnected) {
|
|
434
|
+
return <div>Connecting...</div>;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return <div>{sortedMessages.length} messages</div>;
|
|
438
|
+
};
|
|
439
|
+
```
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Internationalization with AI-powered translations in Kokimoki SDK"
|
|
3
|
+
applyTo: "**/*.ts,**/*.tsx"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Internationalization (i18n)
|
|
7
|
+
|
|
8
|
+
Kokimoki SDK provides built-in internationalization support powered by i18next with AI-powered translation capabilities. No manual translation files needed for additional languages.
|
|
9
|
+
|
|
10
|
+
For core SDK concepts, see [Kokimoki SDK](./kokimoki-sdk.instructions.md).
|
|
11
|
+
|
|
12
|
+
Access i18n API via `kmClient.i18n`.
|
|
13
|
+
|
|
14
|
+
## Key Features
|
|
15
|
+
|
|
16
|
+
- Pre-configured i18next instance creation
|
|
17
|
+
- Consistent HTTP-based loading in dev and production
|
|
18
|
+
- AI-powered translation requests with polling support
|
|
19
|
+
- Automatic namespace management
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// src/i18n.ts
|
|
25
|
+
import { initReactI18next } from "react-i18next";
|
|
26
|
+
import { getKmClient } from "@kokimoki/app";
|
|
27
|
+
|
|
28
|
+
const kmClient = getKmClient();
|
|
29
|
+
|
|
30
|
+
// Create i18next instance with React integration
|
|
31
|
+
export const i18n = kmClient.i18n.createI18n({
|
|
32
|
+
use: [initReactI18next],
|
|
33
|
+
fallbackLng: "en",
|
|
34
|
+
defaultNS: "game",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Initialize with primary language
|
|
38
|
+
await kmClient.i18n.init("en");
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API Methods
|
|
42
|
+
|
|
43
|
+
### i18n.createI18n(options?): i18n
|
|
44
|
+
|
|
45
|
+
Create and configure an i18next instance. Call `init()` to initialize with a specific language.
|
|
46
|
+
|
|
47
|
+
**Parameters:**
|
|
48
|
+
|
|
49
|
+
- **options.use**: `Plugin[]` Array of i18next plugins (e.g., `initReactI18next`)
|
|
50
|
+
- **options.fallbackLng**: `string` Fallback language code (default: same as `lng` passed to init)
|
|
51
|
+
- **options.defaultNS**: `string` Default namespace (default: first namespace in config)
|
|
52
|
+
|
|
53
|
+
**Example:**
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { initReactI18next } from "react-i18next";
|
|
57
|
+
|
|
58
|
+
export const i18n = kmClient.i18n.createI18n({
|
|
59
|
+
use: [initReactI18next],
|
|
60
|
+
fallbackLng: "en",
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### i18n.init(lng): Promise<void>
|
|
65
|
+
|
|
66
|
+
Initialize the i18next instance with a specific language. Must call `createI18n()` first.
|
|
67
|
+
|
|
68
|
+
**Parameters:**
|
|
69
|
+
|
|
70
|
+
- **lng**: `string` Language code to initialize with (e.g., 'en', 'de')
|
|
71
|
+
|
|
72
|
+
**Example:**
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
await kmClient.i18n.init("en");
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### i18n.getNamespaces(): string[]
|
|
79
|
+
|
|
80
|
+
Get the list of available namespaces configured in @kokimoki/kit.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
const namespaces = kmClient.i18n.getNamespaces();
|
|
84
|
+
// ['game', 'ui', 'common']
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### i18n.getLanguages(): string[]
|
|
88
|
+
|
|
89
|
+
Get the list of available languages configured in @kokimoki/kit.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const languages = kmClient.i18n.getLanguages();
|
|
93
|
+
// ['en', 'et']
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### i18n.getNamespaceUrl(lng, ns): string
|
|
97
|
+
|
|
98
|
+
Get the URL for a translation namespace file.
|
|
99
|
+
|
|
100
|
+
**Parameters:**
|
|
101
|
+
|
|
102
|
+
- **lng**: `string` Language code
|
|
103
|
+
- **ns**: `string` Namespace name
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
const url = kmClient.i18n.getNamespaceUrl("en", "game");
|
|
107
|
+
// Dev: "/__kokimoki/i18n/en/game.json"
|
|
108
|
+
// Prod: "https://cdn.kokimoki.com/build-123/km-i18n/en/game.json"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## AI Translation API
|
|
112
|
+
|
|
113
|
+
### i18n.getAllLanguagesStatus(): Promise<AllLanguagesStatus>
|
|
114
|
+
|
|
115
|
+
Get the status of all languages that have been requested for AI translation.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
interface AllLanguagesStatus {
|
|
119
|
+
languages: { lng: string; status: LanguageStatus }[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type LanguageStatus = "available" | "processing" | "failed" | "partial";
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Example:**
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
const { languages } = await kmClient.i18n.getAllLanguagesStatus();
|
|
129
|
+
// [{ lng: 'de', status: 'available' }, { lng: 'fr', status: 'processing' }]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### i18n.getTranslationStatus(lng): Promise<TranslationStatus>
|
|
133
|
+
|
|
134
|
+
Get the translation status for a specific language.
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
interface TranslationStatus {
|
|
138
|
+
status: "available" | "processing" | "not_available";
|
|
139
|
+
namespaces: Record<string, NamespaceStatus>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type NamespaceStatus = "available" | "processing" | "failed" | "not_available";
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Example:**
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const status = await kmClient.i18n.getTranslationStatus("de");
|
|
149
|
+
|
|
150
|
+
if (status.status === "available") {
|
|
151
|
+
i18next.changeLanguage("de");
|
|
152
|
+
} else if (status.status === "processing") {
|
|
153
|
+
// Show loading indicator
|
|
154
|
+
} else {
|
|
155
|
+
// Request translation
|
|
156
|
+
await kmClient.i18n.requestTranslation("de");
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### i18n.requestTranslation(lng): Promise<RequestTranslationResult>
|
|
161
|
+
|
|
162
|
+
Request AI translation for a target language. Triggers background AI translation jobs for all namespaces.
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
interface RequestTranslationResult {
|
|
166
|
+
lng: string;
|
|
167
|
+
status: "started" | "already_processing" | "already_available";
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Example:**
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const result = await kmClient.i18n.requestTranslation("de");
|
|
175
|
+
|
|
176
|
+
if (result.status === "already_available") {
|
|
177
|
+
// Already translated, switch immediately
|
|
178
|
+
i18next.changeLanguage("de");
|
|
179
|
+
} else {
|
|
180
|
+
// Poll until ready
|
|
181
|
+
await kmClient.i18n.pollTranslation("de");
|
|
182
|
+
i18next.changeLanguage("de");
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### i18n.pollTranslation(lng, options?): Promise<void>
|
|
187
|
+
|
|
188
|
+
Poll a translation request until all namespaces are available.
|
|
189
|
+
|
|
190
|
+
**Parameters:**
|
|
191
|
+
|
|
192
|
+
- **lng**: `string` Language code to poll for
|
|
193
|
+
- **options.pollInterval**: `number` Polling interval in ms (default: 2000, min: 1000)
|
|
194
|
+
- **options.timeout**: `number` Timeout in ms (default: 120000)
|
|
195
|
+
- **options.onProgress**: `(status: TranslationStatus) => void` Progress callback
|
|
196
|
+
|
|
197
|
+
**Example:**
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
await kmClient.i18n.pollTranslation("de", {
|
|
201
|
+
timeout: 60000,
|
|
202
|
+
onProgress: (status) => {
|
|
203
|
+
console.log("Overall:", status.status);
|
|
204
|
+
console.log("Namespaces:", status.namespaces);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Now safe to switch
|
|
209
|
+
i18next.changeLanguage("de");
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Common Patterns
|
|
213
|
+
|
|
214
|
+
### Example: Language Switcher with AI Translation
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
import { useState } from "react";
|
|
218
|
+
import { useTranslation } from "react-i18next";
|
|
219
|
+
import { getKmClient } from "@kokimoki/app";
|
|
220
|
+
|
|
221
|
+
const kmClient = getKmClient();
|
|
222
|
+
|
|
223
|
+
const LanguageSwitcher = () => {
|
|
224
|
+
const { i18n } = useTranslation();
|
|
225
|
+
const [loading, setLoading] = useState(false);
|
|
226
|
+
|
|
227
|
+
const switchLanguage = async (lng: string) => {
|
|
228
|
+
// Check if translation is available
|
|
229
|
+
const status = await kmClient.i18n.getTranslationStatus(lng);
|
|
230
|
+
|
|
231
|
+
if (status.status === "available") {
|
|
232
|
+
i18n.changeLanguage(lng);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Request and wait for AI translation
|
|
237
|
+
setLoading(true);
|
|
238
|
+
await kmClient.i18n.requestTranslation(lng);
|
|
239
|
+
await kmClient.i18n.pollTranslation(lng, {
|
|
240
|
+
onProgress: (s) => console.log(`Translating: ${s.status}`),
|
|
241
|
+
});
|
|
242
|
+
setLoading(false);
|
|
243
|
+
|
|
244
|
+
i18n.changeLanguage(lng);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<select
|
|
249
|
+
value={i18n.language}
|
|
250
|
+
onChange={(e) => switchLanguage(e.target.value)}
|
|
251
|
+
disabled={loading}
|
|
252
|
+
>
|
|
253
|
+
<option value="en">English</option>
|
|
254
|
+
<option value="de">Deutsch</option>
|
|
255
|
+
<option value="fr">Français</option>
|
|
256
|
+
</select>
|
|
257
|
+
);
|
|
258
|
+
};
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Example: Using Translations in Components
|
|
262
|
+
|
|
263
|
+
```tsx
|
|
264
|
+
import { useTranslation } from "react-i18next";
|
|
265
|
+
|
|
266
|
+
const GameUI = () => {
|
|
267
|
+
const { t } = useTranslation("game");
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div>
|
|
271
|
+
<h1>{t("title")}</h1>
|
|
272
|
+
<button>{t("startButton")}</button>
|
|
273
|
+
<p>{t("score", { points: 100 })}</p>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Key Points
|
|
280
|
+
|
|
281
|
+
- **Setup**: Call `createI18n()` first, then `init()` with the primary language
|
|
282
|
+
- **React Integration**: Pass `initReactI18next` to `use` option for React hooks support
|
|
283
|
+
- **AI Translation**: Request translations for any language, AI translates from your primary language
|
|
284
|
+
- **Polling**: Use `pollTranslation()` to wait for AI translation to complete
|
|
285
|
+
- **Namespaces**: Translations are organized by namespaces configured in @kokimoki/kit
|