saykit 0.11.1 → 0.13.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 +4 -4
- package/dist/index.d.mts +92 -35
- package/dist/index.mjs +211 -438
- package/package.json +3 -8
- package/skills/saykit/SKILL.md +375 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "saykit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Type-safe i18n library with compile-time macro transforms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
|
-
"!dist/**/*.map"
|
|
25
|
+
"!dist/**/*.map",
|
|
26
|
+
"skills"
|
|
26
27
|
],
|
|
27
28
|
"type": "module",
|
|
28
29
|
"sideEffects": false,
|
|
@@ -36,12 +37,6 @@
|
|
|
36
37
|
"access": "public",
|
|
37
38
|
"provenance": true
|
|
38
39
|
},
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"@messageformat/date-skeleton": "2.0.0-0",
|
|
41
|
-
"@messageformat/number-skeleton": "2.0.0-0",
|
|
42
|
-
"@messageformat/parser": "^5.1.1",
|
|
43
|
-
"messageformat": "^4.0.0"
|
|
44
|
-
},
|
|
45
40
|
"scripts": {
|
|
46
41
|
"check": "tsc --noEmit",
|
|
47
42
|
"build": "tsdown"
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: saykit
|
|
3
|
+
description: Use when adding, changing, or debugging internationalisation with SayKit (the `saykit`, `@saykit/*`, `unplugin-saykit`, or `babel-plugin-saykit` packages), or when a project has a saykit.config.ts. Covers setup, the say and <Say> macros, plurals and formatting, catalogues, views and stores, React client and server usage, extraction, and the mistakes agents make most.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SayKit
|
|
7
|
+
|
|
8
|
+
Compile-time i18n. Messages are written inline, `` say`Hello, ${name}!` `` or `<Say>Hello, {name}!</Say>`. The CLI extracts them to translation files and compiles each locale into a `.js` module of data; a build plugin rewrites each macro to `say.call({ id, ... })`. Nothing parses ICU at runtime, a small evaluator formats the compiled data with `Intl`.
|
|
9
|
+
|
|
10
|
+
Docs: https://saykit.js.org. Source and end-to-end examples (`examples/*`): https://github.com/k0d13/saykit.
|
|
11
|
+
|
|
12
|
+
All SayKit packages are released together and must share one version. Pre-1.0, so APIs still move between minors.
|
|
13
|
+
|
|
14
|
+
## Packages
|
|
15
|
+
|
|
16
|
+
| Package | Purpose | When |
|
|
17
|
+
| ----------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
18
|
+
| `saykit` | Runtime: `createCatalogue`, `createStore`, the `say` macros, formatting | Always, a runtime dependency |
|
|
19
|
+
| `@saykit/config` | `defineConfig` and the `saykit` CLI (`extract`, `compile`, `clean`) | Always, dev dependency |
|
|
20
|
+
| `@saykit/transform-js` | Extracts and rewrites `say` macros in `.js`/`.ts` | Always |
|
|
21
|
+
| `@saykit/transform-jsx` | Same for `<Say>` in `.jsx`/`.tsx` | Any JSX |
|
|
22
|
+
| `@saykit/format-po` | Gettext PO catalogues | Default choice, translators and TMSes know it |
|
|
23
|
+
| `@saykit/format-json` | JSON catalogues; `arb` and `webextension` dialects | Flutter-style ARB, browser extension `_locales/`, plain JSON |
|
|
24
|
+
| `unplugin-saykit` | Bundler plugin | Vite, Rollup, Rolldown, Webpack, Rspack, esbuild, Farm, Bun |
|
|
25
|
+
| `babel-plugin-saykit` | Babel plugin | Next.js, Expo, React Native, any Babel pipeline |
|
|
26
|
+
| `@saykit/react` | `<Say>`, `SayProvider`, `useSay`, `withSay`, `getSay` | React |
|
|
27
|
+
| `@saykit/carbon` | `SayPlugin`, `withSay`, `interaction.say`, `guild.say` | Carbon Discord bots |
|
|
28
|
+
|
|
29
|
+
A project needs the runtime, config, one formatter, the transformers for its file types, and one plugin. Use the project's own package manager.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
### `saykit.config.ts`
|
|
34
|
+
|
|
35
|
+
At the project root. A TypeScript config needs Node 22.18+, or Bun, Deno or tsx.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { defineConfig } from '@saykit/config';
|
|
39
|
+
import po from '@saykit/format-po';
|
|
40
|
+
import js from '@saykit/transform-js';
|
|
41
|
+
import jsx from '@saykit/transform-jsx';
|
|
42
|
+
|
|
43
|
+
export default defineConfig({
|
|
44
|
+
// The first locale is the source locale: the language the code is written
|
|
45
|
+
// in, the only one extraction writes, and the final fallback for every other
|
|
46
|
+
locales: ['en', 'en-GB', 'en-NZ', 'fr'],
|
|
47
|
+
// Optional chains between a locale and the source, most specific first.
|
|
48
|
+
// Every key and value must also appear in `locales`
|
|
49
|
+
fallbackLocales: { 'en-NZ': ['en-GB'] },
|
|
50
|
+
buckets: [
|
|
51
|
+
{
|
|
52
|
+
include: ['src/**/*.{ts,tsx}'],
|
|
53
|
+
exclude: ['src/**/*.d.*.ts'],
|
|
54
|
+
// Must contain {locale} and end in .{extension}
|
|
55
|
+
output: 'src/locales/{locale}.{extension}',
|
|
56
|
+
formatter: po(),
|
|
57
|
+
transformer: [js(), jsx()],
|
|
58
|
+
// Optional: strings with no call site (a manifest, a store listing), keyed by id
|
|
59
|
+
messages: { extensionName: 'Reading Time' },
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A bucket is "these files in, this catalogue out". One bucket is the norm, including for a Next.js app where server and client components share a catalogue. Add more only when two areas genuinely want separate files, such as transactional emails beside a UI.
|
|
66
|
+
|
|
67
|
+
### Bundler
|
|
68
|
+
|
|
69
|
+
Vite, and every other bundler `unplugin` supports (change only the import path: `/rollup`, `/rolldown`, `/webpack`, `/rspack`, `/esbuild`, `/farm`, `/bun`):
|
|
70
|
+
|
|
71
|
+
```ts title="vite.config.ts"
|
|
72
|
+
import react from '@vitejs/plugin-react';
|
|
73
|
+
import saykit from 'unplugin-saykit/vite';
|
|
74
|
+
import { defineConfig } from 'vite';
|
|
75
|
+
|
|
76
|
+
export default defineConfig({
|
|
77
|
+
plugins: [saykit(), react()],
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Next.js, Expo, React Native and plain Babel. Adding a `.babelrc` switches Next from SWC to Babel; there is no SWC plugin:
|
|
82
|
+
|
|
83
|
+
```json title=".babelrc"
|
|
84
|
+
{
|
|
85
|
+
"presets": ["next/babel"],
|
|
86
|
+
"plugins": ["saykit"]
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```js title="babel.config.js"
|
|
91
|
+
module.exports = function (api) {
|
|
92
|
+
api.cache(true);
|
|
93
|
+
return {
|
|
94
|
+
presets: ['babel-preset-expo'],
|
|
95
|
+
plugins: ['saykit'],
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Next.js and Metro also need `withSayKit` around their config, which compiles the catalogues (see below):
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
// next.config.mjs
|
|
104
|
+
import { withSayKit } from 'babel-plugin-saykit/next';
|
|
105
|
+
export default withSayKit({});
|
|
106
|
+
|
|
107
|
+
// metro.config.js
|
|
108
|
+
const { withSayKit } = require('babel-plugin-saykit/metro');
|
|
109
|
+
module.exports = withSayKit(getDefaultConfig(__dirname));
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Catalogues are never loaded through a plugin: no loader options, nothing intercepts an import.
|
|
113
|
+
|
|
114
|
+
### Compiled locale modules
|
|
115
|
+
|
|
116
|
+
Each catalogue compiles to a `{locale}.js` beside it (`src/locales/fr.po` → `src/locales/fr.js`), with the `fallbackLocales` chain already merged in, plus a `{locale}.d.ts` declaring `Record<string, Message>`. The build plugin does this: `unplugin-saykit` when the build starts, `withSayKit` when Next or Metro loads its config, and both recompile in dev whenever a catalogue changes. `saykit extract` compiles too. Gitignore the `.js` modules and commit the `.d.ts`, which never changes, so `tsc` passes on a fresh clone.
|
|
117
|
+
|
|
118
|
+
No `pre*` script is needed with a plugin. Without one (plain `babel`, a script Node runs directly), run `saykit compile` before anything imports a locale:
|
|
119
|
+
|
|
120
|
+
```json title="package.json"
|
|
121
|
+
{
|
|
122
|
+
"scripts": {
|
|
123
|
+
"prebuild": "saykit compile"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A formatter whose extension is `.js` cannot be used: its module would overwrite the catalogue.
|
|
129
|
+
|
|
130
|
+
## Writing messages
|
|
131
|
+
|
|
132
|
+
### TypeScript
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
say`Hello, ${name}!`; // Hello, {name}!
|
|
136
|
+
say`Total: ${{ cartTotal: getTotal() }}`; // Total: {cartTotal}
|
|
137
|
+
say`Hello, ${user.name}`; // Hello, {0} (not an identifier, so numbered)
|
|
138
|
+
|
|
139
|
+
say.plural(count, { 0: 'No items', one: '1 item', other: `${count} items` });
|
|
140
|
+
say.ordinal(n, { one: `${n}st`, two: `${n}nd`, few: `${n}rd`, other: `${n}th` });
|
|
141
|
+
say.select(status, { active: 'Active', archived: 'Archived', other: 'Unknown' });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A placeholder is named after the variable when the expression is a bare identifier. Anything else is numbered, which tells a translator nothing; name it with a single-key object, `${{ name: user.name }}`, or lift it into a local first. Keep a sentence in one message: ``say`Hello, ` + name`` gives a translator half a sentence.
|
|
145
|
+
|
|
146
|
+
`plural`, `ordinal` and `select` need `other`. Exact-number branches are plain digits. Interpolating the selector inside a branch extracts as ICU `#`. Branch keys are ICU keys, identifiers with no hyphens or spaces: `'sold-out'` fails the build, and the fix is to rename the discriminant at its source to `soldOut`, since `select` compares literal strings and a renamed branch would never match.
|
|
147
|
+
|
|
148
|
+
### Context and comments
|
|
149
|
+
|
|
150
|
+
Identical strings that mean different things get a `context`, which gives them separate catalogue entries and shows the translator which is which. A custom `id` gives an optional readable key
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
say({ context: 'noun' })`Post`;
|
|
154
|
+
say({ context: 'verb' })`Post`;
|
|
155
|
+
say({ id: 'checkout.review' })`Review your order`;
|
|
156
|
+
say({ id: 'nav.right', context: 'direction' })`Right`;
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A comment starting with `TRANSLATORS:` directly above a message is extracted alongside it; use it for tone, length limits and anything a translator could not infer. Other comments stay in code.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// TRANSLATORS: Button text, keep under 20 characters
|
|
163
|
+
say`Continue`;
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Numbers, dates and times
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
say`Published ${say.date(post.at, { style: 'long' })}`; // Published {0, date, long}
|
|
170
|
+
say`Total ${say.number(total, { style: '::currency/EUR' })}`; // Total {0, number, ::currency/EUR}
|
|
171
|
+
say.number(1234.5); // "1,234.5" in en, "1 234,5" in fr
|
|
172
|
+
say.time(opensAt, { style: 'short' });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Inside a message the format lands in the catalogue, so a locale can move the number. Alone, the value is formatted for the view's locale but nothing is extracted, there is nothing to translate. `number` styles: `integer`, `percent`, a skeleton (`::currency/EUR`, `::compact-short`), or a pattern (`#,##0.00`). `date` and `time` styles: `short`, `medium`, `long`, `full`, or a skeleton (`::yMMMM`, `::Hm`). There is no `currency` style, only the skeleton. Unknown styles fail the build.
|
|
176
|
+
|
|
177
|
+
### JSX
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
import { Say } from '@saykit/react';
|
|
181
|
+
|
|
182
|
+
<Say>Hello, {name}!</Say>
|
|
183
|
+
<Say>Hello, {{ name: user.name }}!</Say>
|
|
184
|
+
<Say context="noun">Post</Say>
|
|
185
|
+
<Say id="checkout.review">Review your order</Say>
|
|
186
|
+
|
|
187
|
+
{/* TRANSLATORS: Shown on an empty board */}
|
|
188
|
+
<Say>Nothing here yet.</Say>
|
|
189
|
+
|
|
190
|
+
// Elements become tags a translator can move: "Read the <0>docs</0>".
|
|
191
|
+
// say-tag names one: "Read the <link>docs</link>". Name it after what it does to the text.
|
|
192
|
+
<Say>Read the <a href="/docs" say-tag="link">docs</a></Say>
|
|
193
|
+
|
|
194
|
+
<Say.Plural _={count} _0="Nothing here" one={<>{count} item</>} other={<>{count} items</>} />
|
|
195
|
+
<Say.Ordinal _={n} one={<>{n}st</>} two={<>{n}nd</>} few={<>{n}rd</>} other={<>{n}th</>} />
|
|
196
|
+
<Say.Select _={status} active="Active" archived="Archived" other="Unknown" />
|
|
197
|
+
|
|
198
|
+
<Say>Due <Say.Date _={dueAt} style="medium" /></Say>
|
|
199
|
+
<Say.Number _={row.total} style="::currency/EUR" />
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`_` is the selector. Exact-number branches are `_0`, `_1` because a prop cannot start with a digit; extraction strips the underscore. A branch that shows the number must be a fragment, not a string. `{' '}` inside `<Say>` folds into the text. `whitespace` defaults to `true`; pass `whitespace={false}` in React Native when `<Text>` elements sit next to each other, otherwise a bare string outside `<Text>` throws.
|
|
203
|
+
|
|
204
|
+
## Runtime
|
|
205
|
+
|
|
206
|
+
Three objects. A **catalogue** holds locales and where their messages come from. A **view** is one locale bound to its messages: callable, immutable, and what application code holds. A **store** holds the current view and swaps it, which is what switching locale in a browser is.
|
|
207
|
+
|
|
208
|
+
```ts title="src/i18n.ts"
|
|
209
|
+
import { createCatalogue, createStore } from 'saykit';
|
|
210
|
+
import en from './locales/en.js';
|
|
211
|
+
|
|
212
|
+
export const catalogue = createCatalogue({
|
|
213
|
+
en, // inline
|
|
214
|
+
fr: () => import('./locales/fr.js'), // lazy: one chunk, fetched on first load
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// No Locale type is exported; derive it
|
|
218
|
+
export type Locale = (typeof catalogue.locales)[number];
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`createCatalogue` takes a record whose keys are the locales; the first is the fallback `match` resolves to. Never write the messages by hand: keys are content hashes the transform chose, and values are compiled trees, so a hand-written `{ greeting: 'Hello' }` matches nothing. Import the compiled module. A second argument sets view options: `{ bidiIsolation: 'none' }` turns off the FSI/PDI marks written around interpolated values (on by default, as in MessageFormat 2).
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
catalogue.locales; // ['en', 'fr']
|
|
225
|
+
catalogue.match(navigator.languages, cookieLocale); // case-insensitive: exact, then language + script, then language, then locales[0]; nullish and malformed guesses are skipped
|
|
226
|
+
catalogue.loaded('fr'); // false until loaded
|
|
227
|
+
const say = await catalogue.load('fr'); // calls the thunk once; synchronous when already loaded
|
|
228
|
+
catalogue.locale('fr'); // synchronous; throws if a lazy locale has not been loaded
|
|
229
|
+
for (const [locale, say] of catalogue) {
|
|
230
|
+
} // every locale's view
|
|
231
|
+
|
|
232
|
+
say.locale; // 'fr'
|
|
233
|
+
say`Hello`; // formats against fr
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`fallbackLocales` and `match` are different fallbacks. `fallbackLocales` is build time, it decides which files are merged into a locale's catalogue so an untranslated key resolves to another locale's string. `match` is runtime, it decides which key of the catalogue a guess maps to, and never resolves to a locale that is not a key.
|
|
237
|
+
|
|
238
|
+
Browser, where the locale changes:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const initial = catalogue.match(navigator.languages);
|
|
242
|
+
await catalogue.load(initial); // no-op for an eager locale; required when `initial` is lazy
|
|
243
|
+
export const store = createStore(catalogue, initial);
|
|
244
|
+
|
|
245
|
+
store.say`Hello`; // read per call; identity changes on switch
|
|
246
|
+
store.subscribe((say) => {
|
|
247
|
+
document.documentElement.lang = say.locale;
|
|
248
|
+
});
|
|
249
|
+
await store.set('fr'); // loads if needed; last switch wins; a failed load keeps the current view
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
A store exposes `say`, `set` and `subscribe` only; keep the catalogue for `locales`. Do not hold `const say = store.say` across a switch. `createStore` throws when its initial locale is lazy and unloaded, so `await catalogue.load(initial)` before building the store whenever any locale is lazy, including a mixed catalogue.
|
|
253
|
+
|
|
254
|
+
Server: no store. Look a view up per request with `catalogue.load(catalogue.match(guesses))`, splitting an `Accept-Language` header into its tags first (`header.split(',').map((s) => s.split(';')[0].trim())`), since `match` compares whole strings; views are safe to share between requests. For code that cannot be handed a view, `createScope(new AsyncLocalStorage())` from `saykit` gives a `scope.say` resolving to whatever `scope.run(view, fn)` established.
|
|
255
|
+
|
|
256
|
+
## React
|
|
257
|
+
|
|
258
|
+
### Client
|
|
259
|
+
|
|
260
|
+
```tsx title="src/main.tsx"
|
|
261
|
+
import { SayProvider } from '@saykit/react/client';
|
|
262
|
+
import { store } from './i18n';
|
|
263
|
+
|
|
264
|
+
<SayProvider store={store}>
|
|
265
|
+
<App />
|
|
266
|
+
</SayProvider>;
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```tsx title="src/locale-picker.tsx"
|
|
270
|
+
import { useSay } from '@saykit/react/client';
|
|
271
|
+
import { catalogue, store, type Locale } from './i18n';
|
|
272
|
+
|
|
273
|
+
export function LocalePicker() {
|
|
274
|
+
const say = useSay(); // the current View; re-renders on switch
|
|
275
|
+
return (
|
|
276
|
+
<select value={say.locale} onChange={(event) => store.set(event.target.value as Locale)}>
|
|
277
|
+
{catalogue.locales.map((locale) => (
|
|
278
|
+
<option key={locale} value={locale}>
|
|
279
|
+
{locale}
|
|
280
|
+
</option>
|
|
281
|
+
))}
|
|
282
|
+
</select>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
There is no store hook; the store is a module value, import it. `<SayProvider locale={locale} messages={messages}>` is the single-locale form a server hands across the boundary; it cannot switch.
|
|
288
|
+
|
|
289
|
+
### Server components
|
|
290
|
+
|
|
291
|
+
```ts title="src/i18n.ts"
|
|
292
|
+
import { createWithSay } from '@saykit/react/server';
|
|
293
|
+
import { createCatalogue } from 'saykit';
|
|
294
|
+
import en from './locales/en.js';
|
|
295
|
+
import fr from './locales/fr.js';
|
|
296
|
+
|
|
297
|
+
export const catalogue = createCatalogue({ en, fr });
|
|
298
|
+
export const withSay = createWithSay(catalogue);
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```tsx title="app/[locale]/layout.tsx"
|
|
302
|
+
import { SayProvider } from '@saykit/react/client';
|
|
303
|
+
import { getSay } from '@saykit/react/server';
|
|
304
|
+
import { withSay } from '../../i18n';
|
|
305
|
+
|
|
306
|
+
function Layout({ children }: LayoutProps<'/[locale]'>) {
|
|
307
|
+
return (
|
|
308
|
+
<html lang={getSay().locale}>
|
|
309
|
+
<body>
|
|
310
|
+
<SayProvider>{children}</SayProvider>
|
|
311
|
+
</body>
|
|
312
|
+
</html>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default withSay(Layout, (p) => p.params.then((p) => p.locale));
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`withSay(Component, getLocale)` runs `getLocale` (returns a string, array, or nullish, sync or async), passes it through `catalogue.match`, loads the view and establishes it in React's per-request `cache()` before rendering. Wrap **every** layout and page that renders messages, not only the root: Next renders a page before its layout, so an unwrapped page's `getSay()` throws `'getSay' must be called below a 'withSay'`. `<SayProvider>` written in a server component takes no props; the `react-server` build hands the established locale and messages, plain data, to the client. `getSay()` is the server `useSay()`, for the locale as data or a string in a handler. `setSay(view)` establishes a view you resolved yourself. One view per request: a second locale replaces the first for everything rendered after it.
|
|
320
|
+
|
|
321
|
+
## Carbon
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { Client, Command, type CommandInteraction } from '@buape/carbon';
|
|
325
|
+
import { createWithSay, SayPlugin } from '@saykit/carbon';
|
|
326
|
+
|
|
327
|
+
export const withSay = createWithSay(catalogue);
|
|
328
|
+
|
|
329
|
+
class Ping extends withSay(Command) {
|
|
330
|
+
constructor() {
|
|
331
|
+
super((say) => ({ name: say`ping`, description: say`Ping the bot!` }));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async run(interaction: CommandInteraction) {
|
|
335
|
+
await interaction.reply({ content: interaction.say`Pong!` });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
new Client(options, { commands: [new Ping()] }, [new SayPlugin(catalogue)]);
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`withSay` builds command metadata once per locale. `interaction.say` is the interaction locale's view, `guild.say` the guild's.
|
|
343
|
+
|
|
344
|
+
## Workflow
|
|
345
|
+
|
|
346
|
+
1. Write or change a message.
|
|
347
|
+
2. `saykit extract` (or `saykit extract --watch`). Only the source locale file is written; other locales are created header-only and never touched afterwards. Every locale is then compiled.
|
|
348
|
+
3. Translate the other locales by hand or through a TMS. A locale file needs only the entries actually translated; the rest fall back through `fallbackLocales` to the source string at compile time. After pulling translations, the next dev server or build compiles them (or `saykit compile` without a plugin).
|
|
349
|
+
4. `saykit clean` when source strings were removed or reworded: it drops orphaned and empty entries from non-source locales and never adds anything.
|
|
350
|
+
|
|
351
|
+
Commit the catalogues and `.d.ts` files, not the compiled `.js`. CI check that extraction was run:
|
|
352
|
+
|
|
353
|
+
```yaml
|
|
354
|
+
- run: pnpm saykit extract
|
|
355
|
+
- run: git diff --exit-code -- 'src/locales/*.po'
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Macros throw without the plugin, so a test runner needs it too; Vitest picks it up from `vite.config.ts`.
|
|
359
|
+
|
|
360
|
+
Custom formats: a `Formatter` is `{ extension: '.yaml', parse(content) => Message[], stringify(messages, meta) => string }` and a `Transformer` is `{ match(id) => boolean, extract(code, id) => Message[], transform(code, id) => string }`, where `Message` (from `@saykit/config`, not the runtime type) is `{ message, translation?, id?, context?, comments, references }`. See `examples/custom-formatter`.
|
|
361
|
+
|
|
362
|
+
## Errors
|
|
363
|
+
|
|
364
|
+
| Symptom | Cause and fix |
|
|
365
|
+
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
366
|
+
| `'say' is a macro and must be used with the relevant saykit plugin` | The file was not transformed. Check the bucket `include` glob and that the plugin is in the bundler and test config |
|
|
367
|
+
| `No message for <id> in locale 'x'` | Stale or hand-written module. Run `saykit extract`; import the compiled file, never declare messages |
|
|
368
|
+
| `Cannot find module './locales/en.js'` | Not compiled yet. Add the plugin (and `withSayKit` for Next/Metro), or run `saykit compile` where there is none |
|
|
369
|
+
| `Messages for locale 'x' have not been loaded yet` | `catalogue.locale('x')` on a lazy locale. `await catalogue.load('x')` or `store.set('x')` first |
|
|
370
|
+
| `No messages for locale 'x'` | `x` is not a key of the catalogue. Run guesses through `match` first |
|
|
371
|
+
| `'getSay' must be called below a 'withSay'` | This segment is not wrapped. Wrap every layout and page, not only the root |
|
|
372
|
+
| Edited a `.po`, dev server shows old strings | The module was not recompiled. On Next/Metro, check `withSayKit` wraps the config; otherwise run `saykit compile` |
|
|
373
|
+
| Translator sees `{0}` | Placeholder is not an identifier. Name it: `${{ name: value }}` |
|
|
374
|
+
| Build fails on a branch key such as `sold-out` | ICU keys are identifiers. Rename the discriminant at its source |
|
|
375
|
+
| `Text strings must be rendered within a <Text> component` | React Native. `<Say whitespace={false}>` |
|