diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 92e367ee66..58c0db7d9e 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ { - "**/*.{json,js,mjs,ts,yml,md}": "prettier --list-different", - "**/*.{js,mjs,ts}, !test": "eslint --max-warnings 0" + "**/*.{json,js,mjs,mts,cjs,ts,yml,md}": "prettier --list-different", + "**/*.{js,mjs,mts,cjs,ts}, !test": "eslint --max-warnings 0" } diff --git a/CLAUDE.md b/CLAUDE.md index 765abf733c..4c7b82b95f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Companion docs that apply to all agents: `AGENTS.md` (general agent rules) and ` ## Toolchain -- Node version is pinned in `.nvmrc` (use `nvm use`). `engines.node` requires `>=18`. +- Node version is pinned in `.nvmrc` (use `nvm use`). `engines.node` requires `>=22.18.0` — the release that unflagged type stripping, which the `.mts` build scripts need. Node 22.12 is a different milestone (`require(esm)`) and is **not** enough to run them. - Package manager is **Yarn 4 (Berry)**, version pinned via `packageManager` in `package.json` and `yarnPath` in `.yarnrc.yml` (binary committed under `.yarn/releases/`). Any globally installed `yarn` launcher delegates to it. No Corepack setup needed. - `.yarnrc.yml` enables hardening: `enableHardenedMode: true`, `enableScripts: false`, `npmMinimalAgeGate: 3d`. Lifecycle scripts are blocked by default — only packages allowlisted in `package.json#dependenciesMeta` (currently `esbuild`, `husky`) may run install scripts. If a new dep needs lifecycle scripts, add it to `dependenciesMeta` rather than relaxing the global setting. - Clean installs (CI and local sanity checks): `yarn install --immutable`. @@ -19,6 +19,7 @@ Companion docs that apply to all agents: `AGENTS.md` (general agent rules) and ` | Build (types + bundles) | `yarn build` | | Watch dev build | `yarn start` | | Typecheck only | `yarn types` | +| Typecheck the build scripts only | `yarn types:scripts` | | Lint (prettier + eslint, zero warnings) | `yarn lint` | | Auto-fix lint/format | `yarn lint-fix` | | Unit tests (Vitest) | `yarn test` (alias for `yarn test-unit`) | @@ -34,17 +35,27 @@ Single test runs use Vitest's CLI directly: `yarn test-unit path/to/file.test.ts `yarn build` runs two things concurrently: 1. `tsc` — emits **declarations only** (`emitDeclarationOnly: true`) to `dist/types`. `rootDir` is `src/`. -2. `scripts/bundle.mjs` (esbuild) — produces three bundles: - - `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins) - - `dist/cjs/index.browser.js` (browser CJS) - - `dist/esm/index.mjs` (browser ESM) +2. `scripts/bundle.mts` (esbuild) — produces bundles for **three entry points**: + - `index` (the root): `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins), `dist/cjs/index.browser.js` (browser CJS), `dist/esm/index.mjs` (browser ESM) + - `i18n` (`stream-chat/i18n`): the same three variants, `i18n.node.js` / `i18n.browser.js` / `i18n.mjs` + - `i18n-codegen` (`stream-chat/i18n/codegen`), built from `codegen/i18n/`: **ESM only, Node only** — one artifact, `dist/esm/i18n-codegen.mjs`. No browser variant because it reads the filesystem; no CJS variant because nothing needs one (it is invoked by a build script, never bundled, never loaded by a test runner). The CJS flavours of the other two entries exist for React Native's Jest, which loads the _runtime_ in CJS; the generator never enters that path. -`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. There is **no `package.json#browser` field** — it used to zero Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) for browser/RN builds, but the SDK no longer imports any of them (`src/index.ts` is platform-agnostic: global `WebSocket`, global `FormData`, global `atob`). `scripts/bundle.mjs` keeps a `browserIgnoreModules` hook, currently an empty array, for the day that changes. Prefer a platform global or a browser-safe dep over reintroducing a Node-only one. + After building, `assertBundleBoundaries` reads esbuild's `metafile` and fails the build if an entry reached something it must not (see the i18n section). Adding a new entry point without declaring its boundary in `ENTRY_BOUNDARIES` is itself an error. + +`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. The `react-native` + `require` branch must stay pointed at CJS — React Native's Jest runs CJS with `customConditions: ["react-native"]` and does not transform `node_modules`, so an `.mjs` there is a syntax error across every RN suite that touches the module. `typesVersions` mirrors the subpaths for consumers still on `moduleResolution: "node"`. There is **no `package.json#browser` field** — it used to zero Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) for browser/RN builds, but the SDK no longer imports any of them (`src/index.ts` is platform-agnostic: global `WebSocket`, global `FormData`, global `atob`). `scripts/bundle.mts` keeps a `browserIgnoreModules` hook, currently an empty array, for the day that changes. Prefer a platform global or a browser-safe dep over reintroducing a Node-only one. esbuild `define` injects two compile-time constants: `process.env.PKG_VERSION` (read from `package.json`) and `process.env.CLIENT_BUNDLE` (one of `node-cjs`, `browser-cjs`, `browser-esm`). Both are consumed by `StreamChat.getUserAgent()` to produce a bundle-aware UA string. **`tsc`-only code paths do not get this substitution** — these env vars only resolve in the esbuild bundles, so don't gate runtime logic on them in code that callers might import directly via `src/`. `postinstall` installs husky hooks; `prepare` runs `yarn run build` (so consumers installing from a git ref get a built package). +**The build scripts are `.mts`, run by `node` with no loader** — Node strips the types itself, which is unflagged from **22.18.0** (and 24.3.0 on the 24 line; 23.6.0 on the 23 line). `engines.node` is set to that floor deliberately, because `prepare` runs `yarn build`: a **git-ref** install has to be able to execute these scripts. Registry installs never run the build — they get the prebuilt `dist/`. + +Three separate things cover `scripts/`, and each was scoped to miss it at some point: + +- **Types:** `tsconfig.scripts.json`, run by `yarn types:scripts` and folded into `yarn types`. Without it `.mts` annotations are stripped but never checked, which is worse than the JSDoc `@type` comments they replaced. +- **Format:** the `yarn prettier` glob had to gain `mts` — it listed `js,mjs,ts` only, so every `.mts` in the repo silently escaped the format gate. +- **Lint:** `eslint.config.mjs`'s rule blocks list `scripts/**/*.mts` alongside `src/**` and `codegen/**`. Turning this on found `generate-filter-types.mts` importing `yaml` while nothing declared it — it resolved only because `lint-staged` happens to depend on it. `.lintstagedrc.json` has its **own** globs, which also omitted `mts`; both are widened, and note the eslint entry runs with `--max-warnings 0`, so a file matching no config block fails the hook with "no matching configuration was supplied" rather than passing silently. + ## Architecture This is a single-package SDK with **no monorepo**. The public surface is everything re-exported from `src/index.ts` — treat additions there as public API and follow semver carefully (downstream React/Angular/RN SDKs depend on it). @@ -70,6 +81,8 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - `pagination/` — `BasePaginator` (cursor-or-offset, debounced, exposes `state: StateStore`), plus `FilterBuilder` and `ReminderPaginator`. - `reminders/` — `Reminder`, `ReminderManager`, `ReminderTimer` (scheduled-offset reminders with debounced refresh). - `search/` — `BaseSearchSource` + concrete `MessageSearchSource`, `ChannelSearchSource`, `UserSearchSource` orchestrated by `SearchController`. + - `i18n/` — the translation layer shared by the React and React Native SDKs. **Not exported from `src/index.ts`** — see the i18n section below. + - the build-time translation-catalog generator is **not here** — it lives at `codegen/i18n/`, outside `src/` entirely, so the runtime layer physically cannot reach `node:fs`. See the i18n section. - Top-level subsystem files: `poll`, `poll_manager`, `thread`, `thread_manager`, `moderation`, `campaign`, `segment`, `permissions`. - **`types.ts` (~5k lines) + `custom_types.ts` + `types.utility.ts`** — public type surface. **Custom data is extended via module augmentation on the `Custom*Data` interfaces in `custom_types.ts`** (generics were removed in v9; see README). When adding a field that callers may want to extend, expose it through a `Custom*Data` interface rather than reintroducing a generic. @@ -123,6 +136,67 @@ The canonical flow is: Aliases to be aware of: `setUser` → `connectUser`, `disconnect` → `disconnectUser`. Both are deprecated but still present; new code should use the long names. Server-side use (no `window`, or `secret` provided) prints a warning unless `options.allowServerSideConnect: true` is set. +## i18n + +`src/i18n/` holds the translation runtime shared by `stream-chat-react` and +`stream-chat-react-native` — one `Streami18n`, one set of formatters, one date layer. Before this, both +SDKs carried ~1,300 lines of near-duplicate runtime plus a duplicated codegen. See +`specs/i18n-to-core/` for the initiative and `v9-to-v10-migration-guide-i18n.md` for the consumer delta. + +**Three entry points, and the boundaries between them are enforced by the build.** `src/index.ts` must +**never** `export * from './i18n'` — that is the one reflex to resist. `scripts/bundle.mts` asserts from +esbuild's metafile that the root bundle cannot reach `src/i18n/`, `i18next` or `dayjs`, and that +`src/i18n/` cannot reach the Node-only `codegen/`. Both leaks fail invisibly (everything works, the +bundle is just bigger), which is why they are machine-checked. `dist/esm/index.mjs` is expected to stay +byte-identical when only i18n changes. + +**The generator lives at `codegen/i18n/`, outside `src/`.** It is Node-only build tooling that reads the +filesystem — the one thing the SDK's own source must never do — so it is not library source, even though +it _is_ published (two other repos import `stream-chat/i18n/codegen` from their build scripts). Being +outside the library tsconfig is what makes the boundary type-enforced: an import from `src/i18n/` fails +at `tsc` before the metafile assertion ever runs, though the error is an oblique TS6059 "not under +rootDir" rather than something self-explanatory. + +Three things are scoped to `src/` by default and had to be widened for it — check all three if you ever +add another directory beside it, because each fails silently: + +- `tsconfig.codegen.json` emits its declarations to `dist/types/i18n-codegen/`, where `exports` and + `typesVersions` point. `rootDir` must stay `./codegen/i18n` or that path shifts. +- `yarn types` runs **both** projects; `yarn build` runs both `tsc` invocations. +- `eslint.config.mjs` rule blocks list `codegen/**/*.{js,ts}` alongside `src/**/*.{js,ts}`. Without it + the generator inherits no rules at all. + +- **`stream-chat/i18n`** — `Streami18n`, three formatters, `getDateString`, catalog-generic type helpers, + `TranslationBuilder`, generated `LANGUAGE_NAMES`. +- **`stream-chat/i18n/codegen`** — the catalog generator. `typescript` is **injected** via config, never + imported, so core does not depend on the compiler. + +Things that will bite: + +- **Core ships no catalog.** Each UI SDK generates its own `keys.ts` from its `t()` call sites, so the + type helpers are generic over it (`StreamTFunctionFor`). `Bundled` **must** default + to `never`; defaulting to `string` silently disables all key checking. +- **`runtimeDefaults` is a constructor option**, not an import — the catalog belongs to the UI layer. It + is layered under _every_ language, which is what stops a partial dictionary from knocking out formatter + keys. That is guarantee G1 in `test/unit/i18n/Streami18nGuarantees.test.ts`, which is the acceptance + contract for this module: three behavioural guarantees, each written against a real bug. +- **The layering itself lives in `TranslationStore`**, not in `Streami18n` — it needs neither i18next nor + dayjs, so it is tested directly (`test/unit/i18n/TranslationStore.test.ts`) rather than only through an + initialized instance. The store holds flat dictionaries; `Streami18n` adapts them to i18next's nested + `resources` shape, so nothing in the store has to know about namespaces. +- **No module-scope side effects.** Every `Dayjs.extend` goes through `ensureDayjsPlugins()`. This is + what makes `sideEffects: false` accurate — do not reintroduce a top-level `extend` or locale import. +- **`durationFormatter` must use the date library's `.duration()`**, not parse the value as a timestamp. + Parsing reads `600000` as ten minutes past the epoch and renders "57 years ago". This is why + `DateTimeParser` is the _module_, not a parse function. +- **i18next post-processing is global.** A `TranslationTopic` is invoked for every key and must pass + through calls it does not recognize, or it silently rewrites unrelated copy. +- **Vitest forces `TZ=UTC`** (`vite.config.ts`). Date assertions are timezone-sensitive; without it a + local run disagrees with CI by the host's offset. +- Notification identity lives in `src/notifications/types.ts` (`CORE_NOTIFICATION_TYPE`). Emit through + the map, never a raw literal — a test enforces both that and the reverse (every declared identifier + must actually be emitted, so a dead one cannot linger). + ## Conventions to preserve - ESLint uses the flat config (`eslint.config.mjs`); `yarn eslint` runs with `--max-warnings 0`. The pre-commit hook (`.husky/pre-commit` → `lint-staged`) enforces this on staged files. Don't disable rules broadly — scope and justify any `eslint-disable`. @@ -163,7 +237,14 @@ Release branches (`.releaserc.json`): - `master` → `latest` dist-tag (current major: v9). - `release-v8` → `v8` dist-tag, locked to `8.x` range. -- `rc` → prerelease channel. +- `release-v10` → `rc` dist-tag, `prerelease: "rc"`. **This is the branch v10 prereleases are cut + from** — not a branch literally named `rc`. `release.yml` is `workflow_dispatch` and releases from + whatever branch you dispatch it on, gated by + `startsWith(github.ref_name, 'release')`, so a v10 change has to land on `release-v10` before it can + reach npm. The `rc` name survives only as a legacy allowance in that gate. + +Unlike the React and React Native repos, the PR workflows here (`lint`, `unit`, `type`, `size`) carry +**no branch filter**, so a PR into `release-v10` is fully gated with no workflow change needed. ## Things to double-check before claiming done diff --git a/codegen/i18n/callSites.ts b/codegen/i18n/callSites.ts new file mode 100644 index 0000000000..4c6e65586a --- /dev/null +++ b/codegen/i18n/callSites.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type * as ts from 'typescript'; + +import type { CallSiteCopy, TypeScriptModule } from './types'; + +const DEFAULT_IGNORE_DIRS = ['__tests__', 'mock-builders']; + +/** + * Every `t()` call in the source is the catalog's source of truth. + * + * A prose key exists because a component asks for it and passes its English copy inline; delete the + * call and the key is gone. That is what removes the need for a checked-in `en.json` and for an + * extract / remove-unused-keys pass, and it makes a dead prose key structurally impossible. + * + * The only keys that cannot be described this way are the ones with no inline copy — a formatter + * expression, or a key built from a runtime value. Those come from `runtimeDefaults`, and the generator + * cross-checks the two. + */ +const isTCallee = (tsModule: TypeScriptModule, expr: ts.Expression): boolean => + (tsModule.isIdentifier(expr) && expr.text === 't') || + (tsModule.isPropertyAccessExpression(expr) && expr.name.text === 't'); + +export const sourceFiles = ({ + ignoreDirs = DEFAULT_IGNORE_DIRS, + srcRoot = 'src', +}: { + ignoreDirs?: string[]; + srcRoot?: string; +} = {}): string[] => { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (ignoreDirs.includes(entry.name)) continue; + walk(full); + } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + out.push(full); + } + } + }; + walk(srcRoot); + return out; +}; + +export const readCallSiteCopy = ({ + ignoreDirs, + srcRoot, + ts: tsModule, +}: { + ts: TypeScriptModule; + ignoreDirs?: string[]; + srcRoot?: string; +}): CallSiteCopy => { + const copy = new Map(); + const withoutCopy = new Map(); + const pluralWithoutCopy = new Map(); + const conflicts: CallSiteCopy['conflicts'] = []; + + const record = (key: string, value: string, file: string) => { + const existing = copy.get(key); + if (existing !== undefined && existing !== value) { + conflicts.push({ a: existing, b: value, file, key }); + return; + } + copy.set(key, value); + }; + + for (const file of sourceFiles({ ignoreDirs, srcRoot })) { + const sourceFile = tsModule.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + tsModule.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? tsModule.ScriptKind.TSX : tsModule.ScriptKind.TS, + ); + + const visit = (node: ts.Node) => { + if (tsModule.isCallExpression(node) && isTCallee(tsModule, node.expression)) { + const [keyArg, second] = node.arguments; + if (keyArg && tsModule.isStringLiteralLike(keyArg)) { + const key = keyArg.text; + if (second && tsModule.isStringLiteralLike(second)) { + // t('key', 'Copy') + record(key, second.text, file); + } else if (second && tsModule.isObjectLiteralExpression(second)) { + // t('key', { count, defaultValue_one, defaultValue_other }) — the catalog holds the + // `_one` / `_other` forms, never the bare key. + let plurals = 0; + let hasCount = false; + for (const prop of second.properties) { + // `count` can arrive shorthand (`{ count }`), which is not a PropertyAssignment. + if ( + tsModule.isShorthandPropertyAssignment(prop) && + prop.name.text === 'count' + ) { + hasCount = true; + continue; + } + if (!tsModule.isPropertyAssignment(prop)) continue; + const name = prop.name.getText(sourceFile).replace(/['"]/g, ''); + if (name === 'count') hasCount = true; + const suffix = name.match(/^defaultValue_(\w+)$/)?.[1]; + if (suffix && tsModule.isStringLiteralLike(prop.initializer)) { + record(`${key}_${suffix}`, prop.initializer.text, file); + plurals++; + } + } + // A `count` with no inline plural copy is a *bundled plural*: i18next will look up + // `_`, so the guard has to demand that shape rather than the bare key. + if (!plurals) (hasCount ? pluralWithoutCopy : withoutCopy).set(key, file); + } else { + // t('key') — no inline copy, so it has to resolve from runtimeDefaults. + withoutCopy.set(key, file); + } + } + } + tsModule.forEachChild(node, visit); + }; + + visit(sourceFile); + } + + return { conflicts, copy, pluralWithoutCopy, withoutCopy }; +}; diff --git a/codegen/i18n/generate.ts b/codegen/i18n/generate.ts new file mode 100644 index 0000000000..14a7957def --- /dev/null +++ b/codegen/i18n/generate.ts @@ -0,0 +1,226 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readCallSiteCopy } from './callSites'; +import { + formatFailures, + guardBundledPluralShape, + guardConflictingCopy, + guardPrefixCollisions, + guardShadowedKeys, + guardUnresolvableKeys, +} from './guards'; +import { readStringMap } from './stringMaps'; +import type { GeneratedCatalog, GeneratorConfig } from './types'; + +/** Values under these prefixes are dayjs/i18next expressions, not copy. */ +const BUILTIN_FORMATTER_PREFIXES = ['timestamp.', 'duration.']; + +/** + * A catalog entry that is one plural category of a key, e.g. `x.y_other`. + * + * These are excluded from the emitted `BundledTranslationKey` union: a call site passes the *bare* key + * and `StreamTFunctionFor`'s plural overload already accepts it, so listing the suffixed forms as + * bundled keys would offer `t('x.y_other')` — a call that resolves nothing. + */ +const PLURAL_CATEGORY_SUFFIX = /_(zero|one|two|few|many|other)$/; + +/** + * Two ways English hides inside a formatter expression, both of which have to be reported: a translator + * working from the JSON export never sees these keys otherwise. + */ +const hasEnglishWords = (value: string) => + // Day words baked into a `calendarFormats` argument, e.g. `[Yesterday]` — dayjs escapes literal text + // in brackets. + [...value.matchAll(/\[([^\]]+)\]/g)].some(([, literal]) => + /[A-Za-z]{2}/.test(literal), + ) || + // Prose sitting beside the interpolation, e.g. `Last seen {{ timestamp | … }}`. Each expression is + // matched individually — a greedy `{{[\s\S]*}}` would span from the first `{{` to the last `}}` and + // swallow the prose between two of them. Stopping at `}}` rather than any `}` is what keeps the + // nested braces of a `calendarFormats` argument inside the match. + /[A-Za-z]{2}/.test(value.replace(/\{\{(?:[^}]|\}(?!\}))*\}\}/g, '')); + +/** + * Builds the catalog and runs every guard, without writing anything. + * + * Separate from {@link generateI18nKeys} so a test — or a caller wanting to inspect before committing — + * can get the failures as data rather than as process output. + */ +export const buildCatalog = (config: GeneratorConfig): GeneratedCatalog => { + const { runtimeDefaultsPath, ts } = config; + + const runtimeDefaults = readStringMap({ + exportName: 'runtimeDefaults', + file: runtimeDefaultsPath, + ts, + }); + const { + conflicts, + copy: inlineCopy, + pluralWithoutCopy, + withoutCopy, + } = readCallSiteCopy({ + ignoreDirs: config.ignoreDirs, + srcRoot: config.srcRoot, + ts, + }); + + const catalogEntries = new Map([...inlineCopy, ...runtimeDefaults]); + const keys = [...catalogEntries.keys()].sort(); + const catalog = new Map(keys.map((key) => [key, catalogEntries.get(key) as string])); + + const failures = [ + guardConflictingCopy(conflicts), + guardUnresolvableKeys({ + pluralWithoutCopy, + runtimeDefaults, + runtimeDefaultsPath, + withoutCopy, + }), + guardBundledPluralShape({ pluralWithoutCopy, runtimeDefaults, runtimeDefaultsPath }), + guardShadowedKeys({ inlineCopy, runtimeDefaults, runtimeDefaultsPath }), + guardPrefixCollisions(keys), + ].filter((failure): failure is NonNullable => failure !== null); + + return { bundledKeys: [...runtimeDefaults.keys()].sort(), catalog, failures }; +}; + +const renderKeysFile = ({ + bundledKeys, + catalog, + emitBundledKeyUnion, +}: { + bundledKeys: string[]; + catalog: Map; + emitBundledKeyUnion?: boolean; +}): string => { + const lines: string[] = [ + '// AUTO-GENERATED — do not edit by hand.', + '// Regenerate with `yarn build-translations`. CI fails if this file is out of sync.', + '//', + '// Type-only: no runtime value is emitted, so this adds nothing to the bundle.', + '', + '/**', + ' * Every translation entry shipped with the SDK, mapped to its English copy.', + ' *', + ' * Plural entries appear as `_one` / `_other`; call sites use the bare `` and', + ' * pass `count`.', + ' */', + 'export type TranslationCatalog = {', + ]; + + for (const [key, value] of catalog) { + lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)};`); + } + lines.push('};', ''); + + if (emitBundledKeyUnion) { + lines.push( + '/**', + ' * Keys whose copy is bundled rather than passed inline at the call site.', + ' *', + ' * They reach `t()` as runtime values — a JSX prop, a ternary branch, a lookup table — so there', + ' * is nowhere to write a `defaultValue`. Call sites pass the key alone.', + ' */', + 'export type BundledTranslationKey =', + ); + for (const key of bundledKeys) lines.push(` | ${JSON.stringify(key)}`); + lines.push(';', ''); + } + + return lines.join('\n'); +}; + +/** + * Regenerates an SDK's translation catalog from its `t()` call sites and bundled defaults. + * + * Throws on a guard failure with every failure formatted, so the caller's script exits non-zero and CI + * fails. The catalog is written only when all guards pass. + */ +export const generateI18nKeys = (config: GeneratorConfig): GeneratedCatalog => { + const log = config.log ?? ((message: string) => console.log(message)); + const result = buildCatalog(config); + + if (result.failures.length) { + throw new Error(formatFailures(result.failures)); + } + + const { bundledKeys, catalog } = result; + const keys = [...catalog.keys()]; + + fs.mkdirSync(path.dirname(config.keysOut), { recursive: true }); + fs.writeFileSync( + config.keysOut, + renderKeysFile({ + bundledKeys: bundledKeys.filter((key) => !PLURAL_CATEGORY_SUFFIX.test(key)), + catalog, + emitBundledKeyUnion: config.emitBundledKeyUnion, + }), + ); + + if (config.fixtureOut) { + fs.mkdirSync(path.dirname(config.fixtureOut), { recursive: true }); + fs.writeFileSync( + config.fixtureOut, + `${JSON.stringify(Object.fromEntries(catalog), null, 2)}\n`, + ); + } + + log( + `generated ${config.keysOut} (${keys.length} entries, type-only) — ` + + `${keys.length - bundledKeys.length} from inline defaults, ${bundledKeys.length} bundled`, + ); + + if (config.json) { + const formatterPrefixes = [ + ...BUILTIN_FORMATTER_PREFIXES, + ...(config.extraFormatterPrefixes ?? []), + ]; + const isFormatterKey = (key: string) => + formatterPrefixes.some((prefix) => key.startsWith(prefix)); + + const exported = config.json.includeFormats + ? keys + : keys.filter((k) => !isFormatterKey(k)); + fs.writeFileSync( + config.json.out, + `${JSON.stringify( + Object.fromEntries(exported.map((key) => [key, catalog.get(key)])), + null, + 2, + )}\n`, + ); + + log( + `wrote ${config.json.out} (${exported.length} ${ + config.json.includeFormats + ? 'entries, formatter expressions included' + : 'translatable entries' + })`, + ); + + const excluded = keys.filter((key) => !exported.includes(key)); + if (excluded.length) { + // Excluding formatter expressions does drop some translatable text: a few embed English day + // words. It is not translatable *as copy* — the format string has to be rewritten — so it is + // named here and handled by overriding the key. Detected rather than hardcoded, so the list + // cannot go stale. + const withEnglish = excluded.filter((key) => + hasEnglishWords(catalog.get(key) as string), + ); + log( + ` excluded ${excluded.length} formatter expressions (${formatterPrefixes.join(', ')}) — ` + + `not copy, and a TMS that translates them breaks date rendering. Pass --all to include ` + + `them.` + + (withEnglish.length + ? `\n ${withEnglish.length} of them do carry English copy and must be translated by ` + + `overriding the key:\n${withEnglish.map((key) => ` ${key}`).join('\n')}` + + (config.migrationGuideRef ? `\n see ${config.migrationGuideRef}.` : '') + : ''), + ); + } + } + + return result; +}; diff --git a/codegen/i18n/guards.ts b/codegen/i18n/guards.ts new file mode 100644 index 0000000000..d3e059a6bd --- /dev/null +++ b/codegen/i18n/guards.ts @@ -0,0 +1,178 @@ +import type { CallSiteCopy, GuardFailure } from './types'; + +/** + * The five hard-fail checks the catalog has to pass. + * + * Each is a pure function returning failures as data. The guard both UI SDKs carried that is *not* here + * — checking that an `EXTERNAL_STRING_KEYS` entry's wording matched the key's catalog copy — is gone + * because the map it policed is gone: notifications now resolve through a stable identifier instead of + * by matching English prose. + */ + +/** A key must render one thing. */ +export const guardConflictingCopy = ( + conflicts: CallSiteCopy['conflicts'], +): GuardFailure | null => { + if (!conflicts.length) return null; + return { + entries: conflicts.map( + ({ a, b, file, key }) => + `${key}\n ${JSON.stringify(a)}\n ${JSON.stringify(b)} (${file})`, + ), + kind: 'conflicting-copy', + summary: + `${conflicts.length} key(s) used with conflicting inline copy — a key must render ` + + `one thing:`, + }; +}; + +/** + * A key called without inline copy resolves from the bundled data or not at all. + * + * Without this, i18next renders the raw dotted key in the UI — the failure mode is a user seeing + * `message.status.sent.text` where a word should be. + */ +export const guardUnresolvableKeys = ({ + pluralWithoutCopy, + runtimeDefaults, + runtimeDefaultsPath, + withoutCopy, +}: { + pluralWithoutCopy: CallSiteCopy['pluralWithoutCopy']; + runtimeDefaults: Map; + runtimeDefaultsPath: string; + withoutCopy: CallSiteCopy['withoutCopy']; +}): GuardFailure | null => { + const unresolvable = [ + ...[...withoutCopy].filter(([key]) => !runtimeDefaults.has(key)), + // A plural resolves as `_`, so `_other` — the one category every language has — is + // what has to be bundled. Demanding the bare key here is what used to reject a correct catalog. + // + // A key bundled under the *bare* name is skipped here on purpose: it is not unresolvable, it is + // the wrong shape, and `guardBundledPluralShape` says so precisely. Reporting both would give two + // failures with different advice for one mistake. + ...[...pluralWithoutCopy] + .filter( + ([key]) => !runtimeDefaults.has(`${key}_other`) && !runtimeDefaults.has(key), + ) + .map(([key, file]): [string, string] => [`${key}_other`, file]), + ]; + if (!unresolvable.length) return null; + return { + entries: unresolvable.map(([key, file]) => `${key} (${file})`), + kind: 'unresolvable-key', + summary: + `${unresolvable.length} key(s) are called with no inline default and are missing from ` + + `${runtimeDefaultsPath}.\nThey would render as the raw key. Either pass the English copy ` + + `inline — t('key', 'Copy') — or add an entry to ${runtimeDefaultsPath}:`, + }; +}; + +/** + * A bundled plural must be stored under its category suffixes, not under the bare key. + * + * i18next falls back to an unsuffixed entry when no `_` exists, so the bare form does + * render — it just renders the same string for every count, with plural selection silently dead and no + * error anywhere. Verified against i18next directly: a bundled `'x.y': '{{count}} items'` answers + * `count: 1` with "1 items". + */ +export const guardBundledPluralShape = ({ + pluralWithoutCopy, + runtimeDefaults, + runtimeDefaultsPath, +}: { + pluralWithoutCopy: CallSiteCopy['pluralWithoutCopy']; + runtimeDefaults: Map; + runtimeDefaultsPath: string; +}): GuardFailure | null => { + const bare = [...pluralWithoutCopy].filter(([key]) => runtimeDefaults.has(key)); + if (!bare.length) return null; + return { + entries: bare.map( + ([key, file]) => + `${key}\n bundled as: ${JSON.stringify(runtimeDefaults.get(key))}\n` + + ` called as: t('${key}', { count }) (${file})`, + ), + kind: 'bundled-plural-shape', + summary: + `${bare.length} key(s) are called with \`count\` but bundled under the bare key in ` + + `${runtimeDefaultsPath}.\ni18next resolves plurals as \`_\`, so this renders ` + + `one form for every count with no error. Split the entry into \`_one\` / \`_other\`:`, + }; +}; + +/** + * A key must not be in both places. + * + * The bundled value wins over a `defaultValue`, so a key in both silently renders the bundled string + * and ignores the call site — meaning an edit to the copy at the call site changes nothing, with no + * error. This is the bug class that used to hide behind a checked-in `en.json`. + */ +export const guardShadowedKeys = ({ + inlineCopy, + runtimeDefaults, + runtimeDefaultsPath, +}: { + inlineCopy: Map; + runtimeDefaults: Map; + runtimeDefaultsPath: string; +}): GuardFailure | null => { + const shadowed = [...runtimeDefaults.keys()].filter((key) => inlineCopy.has(key)); + if (!shadowed.length) return null; + return { + entries: shadowed.map( + (key) => + `${key}\n bundled: ${JSON.stringify(runtimeDefaults.get(key))}\n` + + ` call site: ${JSON.stringify(inlineCopy.get(key))}`, + ), + kind: 'shadowed-key', + summary: + `${shadowed.length} key(s) are in both ${runtimeDefaultsPath} and an inline default.\n` + + `The bundled value wins, so editing the call site would silently change nothing. Remove ` + + `the ${runtimeDefaultsPath} entry:`, + }; +}; + +/** + * A key cannot be both a leaf and a namespace. + * + * With i18next's default `keySeparator: '.'` the shorter key would resolve to an object, and a nested + * resource tree cannot represent both at once. The SDKs set `keySeparator: false` so this is latent + * rather than active — but it is a landmine for anyone who ever flips that, and cheap to prevent. + * + * Compared on segment boundaries, so `poll.title` / `poll.titleText` is fine while `poll.title` / + * `poll.title.text` is not. + */ +export const guardPrefixCollisions = (keys: string[]): GuardFailure | null => { + const keySet = new Set(keys); + const collisions: Array<{ leaf: string; nested: string }> = []; + + for (const key of keys) { + const segments = key.split('.'); + for (let i = 1; i < segments.length; i++) { + const ancestor = segments.slice(0, i).join('.'); + if (keySet.has(ancestor)) collisions.push({ leaf: ancestor, nested: key }); + } + } + + if (!collisions.length) return null; + return { + entries: collisions.map( + ({ leaf, nested }) => `${leaf}\n is a strict prefix of: ${nested}`, + ), + kind: 'prefix-collision', + summary: + `${collisions.length} key(s) are a strict prefix of another key — a key cannot be both a ` + + `leaf and a namespace. Rename one, usually by giving the shorter key a modality segment ` + + `(.label / .text / .title):`, + }; +}; + +/** Formats failures the way the generator prints them before exiting. */ +export const formatFailures = (failures: GuardFailure[]): string => + failures + .map( + ({ entries, summary }) => + `\n${summary}\n${entries.map((e) => ` ${e}`).join('\n')}`, + ) + .join('\n'); diff --git a/codegen/i18n/index.ts b/codegen/i18n/index.ts new file mode 100644 index 0000000000..dfa22c2d54 --- /dev/null +++ b/codegen/i18n/index.ts @@ -0,0 +1,29 @@ +/** + * Build-time codegen for a UI SDK's translation catalog, published as `stream-chat/i18n/codegen`. + * + * **Node-only.** This reads the filesystem and uses the TypeScript parser API, so it must never be + * reachable from `stream-chat/i18n` — which is why it lives beside `src/i18n/` rather than inside it. + * `scripts/bundle.mts` asserts that boundary at build time. + * + * `typescript` is injected through {@link GeneratorConfig.ts} rather than imported, so `stream-chat` + * does not depend on the compiler. + * + * Each SDK keeps a thin script that supplies its own paths: + * + * ```ts + * import ts from 'typescript'; + * import { generateI18nKeys } from 'stream-chat/i18n/codegen'; + * + * generateI18nKeys({ + * ts, + * runtimeDefaultsPath: 'src/i18n/runtimeDefaults.ts', + * keysOut: 'src/i18n/keys.ts', + * fixtureOut: 'src/i18n/__tests__/catalog.fixture.json', + * }); + * ``` + */ +export * from './callSites'; +export * from './generate'; +export * from './guards'; +export * from './stringMaps'; +export * from './types'; diff --git a/codegen/i18n/stringMaps.ts b/codegen/i18n/stringMaps.ts new file mode 100644 index 0000000000..16214bf809 --- /dev/null +++ b/codegen/i18n/stringMaps.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import type * as ts from 'typescript'; + +import type { TypeScriptModule } from './types'; + +/** + * Reads a flat `Record` export out of a source file. + * + * Parsed rather than imported: `await import()` works under Node's type stripping but warns + * `MODULE_TYPELESS_PACKAGE_JSON` on every run, and the SDK packages cannot be `"type": "module"`. + * + * Throws rather than exiting, so a caller — including a test — can handle the failure. + */ +export const readStringMap = ({ + exportName, + file, + ts: tsModule, +}: { + exportName: string; + file: string; + ts: TypeScriptModule; +}): Map => { + if (!fs.existsSync(file)) { + throw new Error( + `i18n-codegen: could not read the file expected to export \`${exportName}\`: ${file}`, + ); + } + + const source = tsModule.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + tsModule.ScriptTarget.Latest, + true, + tsModule.ScriptKind.TS, + ); + + const out = new Map(); + let found = false; + + tsModule.forEachChild(source, (node) => { + if (!tsModule.isVariableStatement(node)) return; + + for (const declaration of node.declarationList.declarations) { + if ( + !tsModule.isIdentifier(declaration.name) || + declaration.name.text !== exportName || + !declaration.initializer + ) { + continue; + } + + // `= { … } as const` and `satisfies …` are both fine. + let initializer: ts.Expression = declaration.initializer; + while ( + tsModule.isAsExpression(initializer) || + tsModule.isSatisfiesExpression(initializer) + ) { + initializer = initializer.expression; + } + if (!tsModule.isObjectLiteralExpression(initializer)) continue; + + found = true; + for (const property of initializer.properties) { + if (!tsModule.isPropertyAssignment(property)) { + throw new Error( + `i18n-codegen: ${exportName} in ${file} must be a flat object of string literals, got: ` + + property.getText(source).slice(0, 80), + ); + } + if ( + !tsModule.isStringLiteralLike(property.name) || + !tsModule.isStringLiteralLike(property.initializer) + ) { + throw new Error( + `i18n-codegen: ${exportName} entries must be 'quoted.key': 'string literal', got: ` + + property.getText(source).slice(0, 80), + ); + } + out.set(property.name.text, property.initializer.text); + } + } + }); + + if (!found) { + throw new Error( + `i18n-codegen: could not find an exported \`${exportName}\` object literal in ${file}`, + ); + } + + return out; +}; diff --git a/codegen/i18n/types.ts b/codegen/i18n/types.ts new file mode 100644 index 0000000000..dec8fafa4d --- /dev/null +++ b/codegen/i18n/types.ts @@ -0,0 +1,100 @@ +import type * as ts from 'typescript'; + +/** + * The TypeScript module, injected by the caller. + * + * Injected rather than imported so `stream-chat` never depends on the compiler. Only the parser API is + * used — no `Program`, no type checker — so this needs no tsconfig and is fast. Both UI SDKs already + * have `typescript` as a devDependency, which is the only place this runs. + */ +export type TypeScriptModule = typeof ts; + +export type GeneratorConfig = { + /** The TypeScript module. See {@link TypeScriptModule}. */ + ts: TypeScriptModule; + /** Path to the file exporting `runtimeDefaults`. */ + runtimeDefaultsPath: string; + /** Where to write the generated catalog. */ + keysOut: string; + /** Source root to scan for `t()` call sites. Default `'src'`. */ + srcRoot?: string; + /** Directory names to skip while scanning. Default `['__tests__', 'mock-builders']`. */ + ignoreDirs?: string[]; + /** + * Where to write a JSON data twin of the catalog. + * + * `keys.ts` is type-only, so no runtime test can iterate it. This is what lets a test render every + * key and assert none surfaces as its own dotted path — the strongest regression net in the i18n + * suite. Put it under `__tests__` so it never reaches the published build. + */ + fixtureOut?: string; + /** + * Emit a `BundledTranslationKey` union alongside `TranslationCatalog`. + * + * Prefix-matching `timestamp.` / `duration.` is not enough for an SDK whose bundled set also includes + * ordinary prose resolved by name at runtime (screen-reader labels, lookup-table entries). + */ + emitBundledKeyUnion?: boolean; + /** + * Extra prefixes whose values are expressions rather than copy, added to the built-in + * `timestamp.` / `duration.`. Excluded from the translator-facing JSON export. + */ + extraFormatterPrefixes?: string[]; + /** Write a translator-facing JSON export. */ + json?: { + out: string; + /** Include formatter expressions. Off by default: a TMS that translates them breaks dates. */ + includeFormats?: boolean; + }; + /** Doc reference quoted in the "these carry English copy" hint. */ + migrationGuideRef?: string; + /** Where to report progress. Defaults to `console.log`. */ + log?: (message: string) => void; +}; + +export type CallSiteCopy = { + /** `key -> English copy` for every key written with an inline default. */ + copy: Map; + /** + * `key -> file` for keys called with no inline copy — `t('timestamp.MessageTimestamp', {…})`. + * These must be present in `runtimeDefaults` or they render as the raw key. + */ + withoutCopy: Map; + /** + * `key -> file` for *plural* keys called with no inline copy — `t('x.y', { count })`. + * + * Tracked apart from {@link CallSiteCopy.withoutCopy} because i18next resolves these as + * `_`, never as the bare key. Checking them the same way demanded exactly the entry + * shape that does not work at runtime while accepting the one that silently never pluralizes. + */ + pluralWithoutCopy: Map; + /** Keys seen with two different inline copies — a key must render one thing. */ + conflicts: Array<{ key: string; a: string; b: string; file: string }>; +}; + +export type GuardFailureKind = + | 'conflicting-copy' + | 'unresolvable-key' + | 'shadowed-key' + | 'prefix-collision' + | 'bundled-plural-shape'; + +/** + * A guard failure, as data. + * + * Returned rather than printed so tests can assert on the failure itself instead of scraping stderr — + * which is most of why the fixture suite for this was as large as it was. + */ +export type GuardFailure = { + kind: GuardFailureKind; + summary: string; + entries: string[]; +}; + +export type GeneratedCatalog = { + /** Every key mapped to its English copy, sorted. */ + catalog: Map; + /** Keys resolved from `runtimeDefaults` rather than an inline default. */ + bundledKeys: string[]; + failures: GuardFailure[]; +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index e51175f3d6..fe35aae7d3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,7 +13,7 @@ export default tseslint.config( { name: 'default', extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['src/**/*.{js,ts}'], + files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}', 'scripts/**/*.mts'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, @@ -100,7 +100,7 @@ export default tseslint.config( }, { ignores: ['src/gen/**'], - files: ['src/**/*.{js,ts}'], + files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}', 'scripts/**/*.mts'], plugins: { jsdoc, }, diff --git a/package.json b/package.json index 126b56bdc6..05cd41e913 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,37 @@ }, "node": "./dist/cjs/index.node.js", "default": "./dist/esm/index.mjs" + }, + "./i18n": { + "types": "./dist/types/i18n/index.d.ts", + "browser": { + "import": "./dist/esm/i18n.mjs", + "require": "./dist/cjs/i18n.browser.js" + }, + "react-native": { + "import": "./dist/esm/i18n.mjs", + "require": "./dist/cjs/i18n.browser.js" + }, + "node": "./dist/cjs/i18n.node.js", + "default": "./dist/esm/i18n.mjs" + }, + "./i18n/codegen": { + "types": "./dist/types/i18n-codegen/index.d.ts", + "default": "./dist/esm/i18n-codegen.mjs" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "i18n": [ + "./dist/types/i18n/index.d.ts" + ], + "i18n/codegen": [ + "./dist/types/i18n-codegen/index.d.ts" + ] } }, + "sideEffects": false, "license": "SEE LICENSE IN LICENSE", "keywords": [ "chat", @@ -40,11 +69,14 @@ ], "files": [ "/dist", + "/codegen", "/src" ], "dependencies": { "@stream-io/logger": "^2.0.0", "axios": "^1.19.0", + "dayjs": "^1.11.13", + "i18next": "^26.3.6", "linkifyjs": "^4.3.3" }, "devDependencies": { @@ -72,15 +104,16 @@ "sinon": "^12.0.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "yaml": "^2.8.4" }, "scripts": { - "build": "rm -rf dist && concurrently 'tsc' './scripts/bundle.mjs'", - "start": "concurrently 'tsc --watch' './scripts/bundle.mjs --watch'", - "types": "tsc --noEmit", + "build": "rm -rf dist && concurrently 'tsc' 'tsc -p tsconfig.codegen.json' './scripts/bundle.mts'", + "start": "concurrently 'tsc --watch' './scripts/bundle.mts --watch'", + "types": "tsc --noEmit && tsc -p tsconfig.codegen.json --noEmit && yarn run types:scripts", "lint": "yarn run prettier && yarn run eslint", "lint-fix": "yarn run eslint-fix; yarn run prettier-fix", - "prettier": "prettier '**/*.{json,js,mjs,ts,yml,md}' --check", + "prettier": "prettier '**/*.{json,js,mjs,mts,cjs,ts,yml,md}' --check", "prettier-fix": "yarn run prettier --write", "eslint": "eslint --max-warnings 0", "eslint-fix": "yarn run eslint --fix", @@ -94,10 +127,11 @@ "semantic-release": "semantic-release", "postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"", "prepare": "yarn run build", - "generate-client": "./scripts/generate-client.sh" + "generate-client": "./scripts/generate-client.sh", + "types:scripts": "tsc -p tsconfig.scripts.json" }, "engines": { - "node": ">=18" + "node": ">=22.18.0" }, "packageManager": "yarn@4.15.0", "dependenciesMeta": { diff --git a/scripts/apply-custom-data-types.mts b/scripts/apply-custom-data-types.mts index 0114268273..5967b1a3ab 100644 --- a/scripts/apply-custom-data-types.mts +++ b/scripts/apply-custom-data-types.mts @@ -187,9 +187,7 @@ const { values } = parseArgs({ const inputPath = values.input; if (!inputPath) { - console.error( - 'Usage: node scripts/apply-custom-data-types.mts -i ', - ); + console.error('Usage: node scripts/apply-custom-data-types.mts -i '); process.exit(1); } @@ -197,8 +195,7 @@ const absoluteInputPath = resolve(process.cwd(), inputPath); const source = readFileSync(absoluteInputPath, 'utf8'); const CUSTOM_FIELD_RE = /^(\s*)custom(\??):\s*Record;\s*$/; -const CHANNEL_CUSTOM_FIELD_RE = - /^(\s*)channel_custom(\??):\s*Record;\s*$/; +const CHANNEL_CUSTOM_FIELD_RE = /^(\s*)channel_custom(\??):\s*Record;\s*$/; const INTERFACE_OPEN_RE = /^export interface (\w+)\s*(?:extends [^{]+)?\{\s*$/; const INTERFACE_CLOSE_RE = /^\}\s*$/; const FILTER_OPEN_RE = /^\s*(\w+)\??:\s*Filters<\{\s*$/; @@ -224,9 +221,8 @@ const skippedFilterKeys = new Set(); // (starts at 1 for the `Filters<{` itself); `inCustomEntry` tracks whether // we're currently inside the `custom: { ... }` sub-block that carries the // rewritable `type: Record;` line. -let filterContext: - | { key: string; braceDepth: number; inCustomEntry: boolean } - | null = null; +let filterContext: { key: string; braceDepth: number; inCustomEntry: boolean } | null = + null; const countBraces = (line: string) => { let opens = 0; @@ -380,11 +376,7 @@ function computeImportSpecifier(fromFileAbs: string, toRepoRelative: string) { * the top of the file, merging into an existing import from the same module * (deduped and sorted). */ -function applyImport( - fileLines: string[], - identifiers: string[], - specifier: string, -) { +function applyImport(fileLines: string[], identifiers: string[], specifier: string) { const importRe = new RegExp( `^import\\s+type\\s+\\{([^}]*)\\}\\s+from\\s+['"]${escapeRegex(specifier)}['"];?\\s*$`, ); diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs deleted file mode 100755 index c22b71dfd0..0000000000 --- a/scripts/bundle.mjs +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node - -import { resolve } from 'node:path'; -import { builtinModules } from 'node:module'; -import * as esbuild from 'esbuild'; -import packageJson from '../package.json' with { type: 'json' }; -import getPackageVersion from './get-package-version.mjs'; - -// import.meta.dirname is not available before Node 20 -const __dirname = import.meta.dirname; - -const watchModeEnabled = process.argv.includes('--watch') || process.argv.includes('-w'); - -const version = getPackageVersion(); - -const modules = Object.keys({ - ...packageJson.dependencies, - ...packageJson.peerDependencies, -}); - -// do not externalize modules that are ignored in browser field -// externalizing them will cause esbuild to not replace the imports -// in the bundles -const browserIgnoreModules = []; // Object.keys(packageJson.browser); -const browserExternal = modules.filter( - (module) => !browserIgnoreModules.includes(module), -); -const nodeExternal = [...modules, ...builtinModules]; - -/** @type esbuild.BuildOptions */ -const commonBuildOptions = { - entryPoints: [resolve(__dirname, '../src/index.ts')], - bundle: true, - target: 'ES2020', - sourcemap: watchModeEnabled ? 'inline' : 'linked', - define: { - 'process.env.PKG_VERSION': JSON.stringify(version), - }, -}; - -/** - * process.env.CLIENT_BUNDLE values: - * - * - index.js - browser-esm - * - index.browser.cjs - browser-cjs - * - index.node.cjs - node-cjs - */ - -// We build two CJS bundles: for browser and for node. The latter one can be -// used e.g. during SSR (although it makes little sence to SSR chat, but still -// nice for import not to break on server). -const bundles = [ - // CJS (browser & Node) - ['browser', 'node'].map((platform) => ({ - ...commonBuildOptions, - format: 'cjs', - external: platform === 'browser' ? browserExternal : nodeExternal, - entryNames: `[dir]/[name].${platform}`, - outdir: resolve(__dirname, '../dist/cjs'), - platform, - define: { - ...commonBuildOptions.define, - 'process.env.CLIENT_BUNDLE': JSON.stringify(`${platform}-cjs`), - }, - })), - // ESM (browser only) - { - ...commonBuildOptions, - format: 'esm', - external: browserExternal, - outExtension: { '.js': '.mjs' }, - outdir: resolve(__dirname, '../dist/esm'), - entryNames: `[dir]/[name]`, - platform: 'browser', - define: { - ...commonBuildOptions.define, - 'process.env.CLIENT_BUNDLE': JSON.stringify('browser-esm'), - }, - }, -].flat(); - -if (watchModeEnabled) { - const contexts = await Promise.all(bundles.map((config) => esbuild.context(config))); - - await Promise.all(contexts.map((context) => context.watch())); - - console.log('ESBuild is watching for changes...'); -} else { - await Promise.all(bundles.map((config) => esbuild.build(config))); -} diff --git a/scripts/bundle.mts b/scripts/bundle.mts new file mode 100755 index 0000000000..299578076f --- /dev/null +++ b/scripts/bundle.mts @@ -0,0 +1,240 @@ +#!/usr/bin/env node + +import { resolve } from 'node:path'; +import { builtinModules } from 'node:module'; +import * as esbuild from 'esbuild'; +import packageJson from '../package.json' with { type: 'json' }; +import getPackageVersion from './get-package-version.mjs'; + +// import.meta.dirname is not available before Node 20 +const __dirname = import.meta.dirname; + +const watchModeEnabled = process.argv.includes('--watch') || process.argv.includes('-w'); + +const version = getPackageVersion(); + +const { dependencies = {}, peerDependencies = {} } = packageJson as { + dependencies?: Record; + // There are none today. Kept in the spread so that adding one externalizes it automatically rather + // than silently bundling it — `tsc` rejected reading the absent field, which is how this surfaced. + peerDependencies?: Record; +}; + +const modules = Object.keys({ ...dependencies, ...peerDependencies }); + +// do not externalize modules that are ignored in browser field +// externalizing them will cause esbuild to not replace the imports +// in the bundles +const browserIgnoreModules: string[] = []; // Object.keys(packageJson.browser); +const browserExternal = modules.filter( + (module) => !browserIgnoreModules.includes(module), +); +const nodeExternal = [...modules, ...builtinModules]; + +const commonBuildOptions = { + // Name-keyed so `[name]` stays stable per entry. `i18n` is a separate entry point on purpose: it + // pulls in i18next and dayjs, and keeping those out of the root bundle is the whole reason + // `stream-chat/i18n` exists as a subpath. `assertBundleBoundaries` enforces that below. + entryPoints: { + index: resolve(__dirname, '../src/index.ts'), + i18n: resolve(__dirname, '../src/i18n/index.ts'), + }, + bundle: true, + metafile: true, + target: 'ES2020', + sourcemap: watchModeEnabled ? 'inline' : 'linked', + define: { + 'process.env.PKG_VERSION': JSON.stringify(version), + }, + // `satisfies` rather than a `: esbuild.BuildOptions` annotation. The annotation would widen every + // field to its optional declared type, so `...commonBuildOptions.define` below would spread a + // possibly-`undefined` value and stop typechecking. This checks the literal against `BuildOptions` + // while keeping its exact shape. +} satisfies esbuild.BuildOptions; + +/** Dependencies that must never be reachable from the root bundle. */ +const I18N_ONLY_DEPENDENCIES = ['i18next', 'dayjs']; + +/** The generator's source, which lives outside `src/`. */ +const CODEGEN_SOURCES = /(^|\/)codegen\//; + +type EntryBoundary = { + /** Suffix-matched against esbuild's `entryPoint`. */ + entry: string; + /** Bare module specifiers this entry must not import, matched exactly or as a subpath prefix. */ + forbiddenDeps: string[]; + /** Input paths this entry must not reach. `null` means no restriction. */ + forbiddenSources: RegExp | null; +}; + +/** + * What each entry point is forbidden from reaching. Keyed on the entry explicitly rather than by + * elimination, so a new entry gets no rule by accident (and the codegen entry is not told off for + * reaching its own source). + */ +const ENTRY_BOUNDARIES: EntryBoundary[] = [ + { + // The root bundle: no i18n at all, and none of its dependencies. + entry: 'src/index.ts', + forbiddenDeps: I18N_ONLY_DEPENDENCIES, + // Two directories rather than the one `src/i18n(-codegen)?/` pattern this used to be, now that the + // generator sits outside `src/`. + forbiddenSources: /(^|\/)(src\/i18n|codegen)\//, + }, + { + // The runtime i18n layer must not pull in the Node-only build tooling. + entry: 'src/i18n/index.ts', + forbiddenDeps: [], + forbiddenSources: CODEGEN_SOURCES, + }, + { + // The codegen is Node-only by design and has no restriction of its own. + entry: 'codegen/i18n/index.ts', + forbiddenDeps: [], + forbiddenSources: null, + }, +]; + +/** + * Fails the build if an entry point reached something it must not. + * + * Two directions, both a single careless `export * from './i18n'` away: + * - the root bundle must not reach `src/i18n/` or its dependencies, or every consumer of + * `stream-chat` pays for i18next and dayjs whether they translate anything or not; + * - the runtime i18n bundle must not reach `codegen/`, which is Node-only build tooling. + * + * Checked here rather than left to review, because the failure is invisible: everything still works, + * the bundle is just quietly bigger. + * + * The second direction is now *also* enforced by the type system, since `codegen/` sits outside the + * library tsconfig — an import from `src/i18n/` fails at `tsc` first, with a better error. This stays + * as the backstop for a deliberate `require`, which `tsc` would not see. + */ +const assertBundleBoundaries = (metafile: esbuild.Metafile) => { + const failures: string[] = []; + + for (const [outputFile, output] of Object.entries(metafile.outputs)) { + const { entryPoint } = output; + if (!entryPoint) continue; + + // Hoisted out of `output` because a narrowing does not survive into the callback below. + const boundary = ENTRY_BOUNDARIES.find(({ entry }) => entryPoint.endsWith(entry)); + if (!boundary) { + failures.push( + `${outputFile} (entry ${entryPoint}) has no declared boundary — add one to ` + + `ENTRY_BOUNDARIES in scripts/bundle.mts.`, + ); + continue; + } + + const forbidden = { + deps: boundary.forbiddenDeps, + sources: boundary.forbiddenSources, + }; + + const { sources: forbiddenSources } = forbidden; + const leakedSources = forbiddenSources + ? Object.keys(output.inputs).filter((input) => forbiddenSources.test(input)) + : []; + const leakedDeps = (output.imports ?? []) + .map(({ path }) => path) + .filter((path) => + forbidden.deps.some((dep) => path === dep || path.startsWith(`${dep}/`)), + ); + + if (leakedSources.length || leakedDeps.length) { + failures.push( + `${outputFile} (entry ${entryPoint}) must not reach: ` + + [...new Set([...leakedSources, ...leakedDeps])].join(', '), + ); + } + } + + if (failures.length) { + console.error(`\nBundle boundary violated:\n ${failures.join('\n ')}\n`); + process.exit(1); + } +}; + +/** + * process.env.CLIENT_BUNDLE values: + * + * - index.js - browser-esm + * - index.browser.cjs - browser-cjs + * - index.node.cjs - node-cjs + */ + +// We build two CJS bundles: for browser and for node. The latter one can be +// used e.g. during SSR (although it makes little sence to SSR chat, but still +// nice for import not to break on server). +const bundles = [ + // CJS (browser & Node) + (['browser', 'node'] as const).map( + (platform): esbuild.BuildOptions => ({ + ...commonBuildOptions, + format: 'cjs', + external: platform === 'browser' ? browserExternal : nodeExternal, + entryNames: `[dir]/[name].${platform}`, + outdir: resolve(__dirname, '../dist/cjs'), + platform, + define: { + ...commonBuildOptions.define, + 'process.env.CLIENT_BUNDLE': JSON.stringify(`${platform}-cjs`), + }, + }), + ), + // ESM (browser only) + { + ...commonBuildOptions, + format: 'esm', + external: browserExternal, + outExtension: { '.js': '.mjs' }, + outdir: resolve(__dirname, '../dist/esm'), + entryNames: `[dir]/[name]`, + platform: 'browser', + define: { + ...commonBuildOptions.define, + 'process.env.CLIENT_BUNDLE': JSON.stringify('browser-esm'), + }, + } satisfies esbuild.BuildOptions, + // Build-time codegen: **ESM only, and Node only.** + // + // No browser variant because it reads the filesystem. No CJS variant because nothing needs one: it is + // invoked by a build script, never bundled and never loaded by a test runner. Both UI SDKs run it as + // `node scripts/generate-i18n-keys.mts`, and `.mts` is unambiguously ESM. The CJS flavours elsewhere + // in this file exist for React Native's Jest, which loads the *runtime* in CJS and does not transform + // `node_modules` — the generator never enters that path. Shipping a second flavour nothing exercises + // is worse than not shipping it. + // + // A CJS caller is still fine on `await import('stream-chat/i18n/codegen')`, and on plain `require()` + // from Node 20.19 / 22.12 onward. + // + // Kept a separate entry so it never becomes reachable from `stream-chat/i18n`, which + // `assertBundleBoundaries` enforces. + { + entryPoints: { + 'i18n-codegen': resolve(__dirname, '../codegen/i18n/index.ts'), + }, + bundle: true, + metafile: true, + target: 'es2022', + platform: 'node', + format: 'esm', + external: nodeExternal, + sourcemap: watchModeEnabled ? 'inline' : 'linked', + define: { 'process.env.PKG_VERSION': JSON.stringify(version) }, + outExtension: { '.js': '.mjs' }, + outdir: resolve(__dirname, '../dist/esm'), + } satisfies esbuild.BuildOptions, +].flat(); + +if (watchModeEnabled) { + const contexts = await Promise.all(bundles.map((config) => esbuild.context(config))); + + await Promise.all(contexts.map((context) => context.watch())); + + console.log('ESBuild is watching for changes...'); +} else { + const results = await Promise.all(bundles.map((config) => esbuild.build(config))); + results.forEach(({ metafile }) => metafile && assertBundleBoundaries(metafile)); +} diff --git a/scripts/generate-filter-types.mts b/scripts/generate-filter-types.mts index 8fc9f4f30c..500b3bdc46 100644 --- a/scripts/generate-filter-types.mts +++ b/scripts/generate-filter-types.mts @@ -90,7 +90,7 @@ for (const [schemaName, schema] of Object.entries(schemas)) { const filterFields = propertyDef['x-stream-filter-fields']; - let typeName = `${schemaName}${snakeToCamelCase(propertyName)}`; + const typeName = `${schemaName}${snakeToCamelCase(propertyName)}`; const fieldEntries = Object.entries(filterFields).map( ([fieldName, fieldDefinition]) => { diff --git a/scripts/get-package-version.mjs b/scripts/get-package-version.mjs index 28a9439e20..d0a0bc7787 100644 --- a/scripts/get-package-version.mjs +++ b/scripts/get-package-version.mjs @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process'; import packageJson from '../package.json' with { type: 'json' }; -// get the latest version so that "process.env.PKG_VERSION" can be replaced with it in the source code (used for reporting purposes), see bundle.mjs for source +// get the latest version so that "process.env.PKG_VERSION" can be replaced with it in the source code (used for reporting purposes), see bundle.mts for source export default function getPackageVersion() { // "build" script ("prepare" hook) gets invoked when semantic-release runs "npm publish", at that point package.json#version already contains updated next version which we can use let version = packageJson.version; diff --git a/specs/i18n-to-core/decisions.md b/specs/i18n-to-core/decisions.md new file mode 100644 index 0000000000..410a6b67bb --- /dev/null +++ b/specs/i18n-to-core/decisions.md @@ -0,0 +1,196 @@ +# i18n to core — decisions + +Decisions taken, with the reasoning that is not recoverable from the diff. Reversals are recorded +rather than rewritten, since the reason a rejected option was rejected is the useful part. + +## Packaging: a subpath, not the root barrel + +`stream-chat/i18n` is a separate entry point. The layer needs `i18next` and `dayjs`, and core had three +runtime dependencies; putting it in the root barrel would make every consumer — Node/SSR, custom UI, +other SDKs — pay for translation machinery they may never use. + +The boundary is asserted from esbuild's `metafile` at build time, in both directions, because a leak +fails invisibly: everything still works, the bundle is just quietly bigger. Verified by deliberately +adding `export * from './i18n'` to `src/index.ts` and confirming the build fails and names every leaked +file. + +## Dependencies: direct, not optional peers — **reversed** + +First built as optional `peerDependencies` to hold core at three runtime dependencies. **Rejected.** + +Optional peers are not installed. A project with only `stream-chat` installed threw +`MODULE_NOT_FOUND: Cannot find module 'dayjs'` on `require('stream-chat/i18n')`, so the requirement was +declared in core but satisfiable only somewhere else — pushing core's own import onto consumers, and +making the UI SDKs responsible for a dependency core is the one importing. Verified both ways against a +packed tarball. + +Accepted cost: ~2.3 MB unpacked in `node_modules` for a consumer who never translates, and five runtime +dependencies rather than three. Bundle size is unaffected either way — the subpath, not the dependency +kind, is what keeps them out of the root bundle. + +Consequence for the UI SDKs: they should **drop** their own `i18next`/`dayjs` declarations rather than +keep them, since two `i18next` instances mean dictionaries registered on one are read from the other. + +## Type layer: generic over the catalog, not module augmentation + +The derivations (`TranslationKeyOf`, `StreamTFunctionFor`, …) live in core while each SDK's generated +catalog stays upstream, so the helpers take the catalog as a type parameter. + +Module augmentation was rejected for two reasons: two catalogs must be able to coexist in one +TypeScript program (a monorepo typechecking both SDKs in one pass), which a single augmented interface +cannot express; and augmentation is ambient, so an SDK's key union would leak into an integrator's +unrelated `t()` calls — the same objection that kept this out of i18next's `CustomTypeOptions`. + +This does not reintroduce the v9-era generics problem. Those were on runtime domain types and infected +every signature; these are type-only aliases instantiated once per SDK, and the generic never appears +in a user-facing signature. + +Prototyped and typechecked before committing: 614-entry synthetic catalog compiles in 0.44 s with all +eight negative assertions firing. **`Bundled` must default to `never`** — defaulting to `string` would +collapse the prose overload and silently disable all key checking. + +Note the ported comment claiming `CopyFor` exceeds TypeScript's union limit (TS2590) **does +not reproduce** on TS 6.0.3 at real catalog size. The constraint is kept anyway — the payoff is small +and TS's limit is a heuristic on instantiation depth × union breadth, so it can trip on a more +interpolation-heavy catalog — but the justification was rewritten to stop citing an error we cannot +substantiate. + +## Reactivity: `StateStore`, not listeners + +React had a single `setLanguageCallback` that one caller clobbers for all others; RN had five listener +members. Both collapse to one `StateStore`, which both SDKs already consume via `useStateStore`. + +`subscribe` fires synchronously with the current value, which dissolves the queued-override race RN +needed `queuedTFunctionOverride` for: `overrideTFunction` before `init()` is just a store write, and a +later subscriber sees it immediately. `init()` must not clobber it, tracked with a flag. + +## `setLanguage` returns `void` + +It previously returned three different shapes (i18next's `TFunction`, `StreamTFunction`, `undefined`). +No call site in either SDK, either example app, or the docs used the value. Returning `t` is actively +misleading once the store exists: it hands out a value valid only until the next language change. + +## `init()` memoized and never cleared + +RN's `waitForInitializing` cleared its guard on completion, leaving a window where a third caller +re-entered initialization. `this.initPromise ??= this.#doInit()` is genuinely idempotent. Two +independent consumers calling this is the normal case, not an edge case — a UI SDK's chat root and its +overlay host both do. + +## `runtimeDefaults` injected, not imported + +The one addition the move forced. `Streami18n` imported it from a sibling file, and that file is +per-SDK catalog data core cannot import. Each SDK's public export becomes a thin subclass injecting its +own, so `new Streami18n(...)` keeps working verbatim for integrators while the layering guarantee (G1) +stays tested in core. + +## `TranslationBuilder`: plumbing down, topics stay up + +React routes notification translation through an i18next post-processor; RN dispatches at the render +site. Both are legitimate, and the choice is a UI-layer concern core should not make. Only the +mechanism moves, so RN can adopt the topic later with no core change. + +Non-obvious property worth keeping in mind: i18next post-processing is configured **globally**, so a +topic is invoked for _every_ key and must pass through calls it does not recognize. Getting that wrong +silently rewrites unrelated copy, so there is a test for it. + +## Formatters: three, with no `relativeCompactDateFormatter` + +The React Native SDK shipped a standalone `relativeCompactDateFormatter` that hardcoded `'Today'` and +`` `${n}d ago` `` — untranslatable by any dictionary, and invisible to the English-prose guard. Its +behaviour is `timestampFormatter` with `relativeCompact: true`, whose wording goes through `t()`. + +It was initially kept as a `@deprecated` alias so existing `timestamp.*` expressions would keep working. +That was rejected: a breaking release should not ship a second name for one behaviour with a countdown +on it. The alias is gone, and the one expression that used it — the React Native SDK's +`timestamp.PollVote` — becomes `{{ timestamp | timestampFormatter(relativeCompact: true) }}`. + +`durationFormatter` is also fixed to `FormatterFactory`; the `` / `` +split was a type bug that forced a cast in the React SDK. + +## dayjs: no module-scope side effects + +Every `Dayjs.extend` moved into `ensureDayjsPlugins()`, called from the constructor and from +`defaultDateTimeParser` — the latter is what keeps a standalone `getDateString()` working with no +instance in play. That makes `sideEffects: false` accurate for the first time. + +RN's module-scope `Dayjs.updateLocale('en', { calendar, format })` is **not** ported: it rewrote +`L`/`LL`/`LT` for the entire host app, which a chat SDK should not do. + +The `import 'dayjs/locale/en'` both SDKs carried is deleted — dayjs registers `en` before any import +runs, so it was a no-op. + +Explicit `.js` on dayjs subpath imports. `dayjs` stays external, so the specifier survives verbatim +into `dist/esm/i18n.mjs` and has to be valid Node ESM. + +The `formats: {}` / `relativeTime: {}` stubs in the English locale skeleton were initially removed as +cruft and **restored**: `Dayjs.locale()` takes an `ILocale`, which declares both as required, so +omitting them is a type error. Comment added, since their purpose is not self-evident. + +## `Intl.PluralRules` coverage is warned about, not polyfilled + +Hermes ships a partial ICU: the constructor exists but silently falls back to the root locale's rules — +`{ other }` only — for locales it lacks data for. A dictionary correctly supplying `_few` / `_many` then +renders none of them, with no error, locale-specific and order-dependent. + +Core cannot install the polyfill (`intl-pluralrules` is an RN-only need and would be wasted bytes +elsewhere), but it can detect the inadequate environment and say so. Checked inside `init()`, which is +the last moment a polyfill could still have been loaded in time — i18next caches an `Intl.PluralRules` +per language during init. + +## Codegen: `typescript` injected, four guards + +Injected via config so core does not depend on the compiler; only the parser API is used, so no +`Program` and no type checker. + +Four guards, not the five RN had. The dropped one policed `EXTERNAL_STRING_KEYS` — that map is gone, +because notifications resolve through a stable identifier instead of by matching English prose. + +Guards return failures as data with a thin printer on top, so tests assert on the failure rather than +scraping stderr and run in-process rather than spawning. That is most of why the SDK-side suite was 381 +lines. + +## Naming: `Streami18n`, matching what both SDKs already ship + +Core first named the class `StreamI18n` and each SDK re-exported `Streami18n` as a `@deprecated` alias +for one cycle. That was rejected: the capital `I` is cosmetic, `Streami18n` is the name both SDKs have +shipped and documented for years, and a deprecated alias in a breaking release is cruft with a countdown +attached. Core is named `Streami18n`, so integrators rename nothing and no alias exists. + +`getTranslators()` went the same way — it was a `@deprecated` alias for `init()`. `init()` is the better +name (it initializes; it is not a getter), so the old one is removed outright rather than carried. + +## Notification `type`, not `code` + +Keeping the field name. Renaming to match its own mislabelled JSDoc would touch ~120 emission sites +across three repos plus two dispatcher maps, for no benefit. The JSDoc was corrected instead. + +`type?: CoreNotificationType | (string & {})` keeps the field open, mirroring `NotificationSeverity` +two lines above it in the same file. Narrowing to a closed union would break the ~120 identifiers the +SDKs and integrators emit. + +## Poll validation errors keep `message` alongside `code` + +A bare code would be smaller but strands every consumer without an i18n layer. Keeping `message` means +a plain-JS integrator gets a compile error with a one-property fix (`errors.name.message`) rather than +a silently blank field, and an unrecognized code degrades to readable text. + +They are deliberately **not** notifications: field-level form state rendered inline next to an input, +where a toast per keystroke would be wrong. + +## Corrections to the initial analysis + +Recorded because each was asserted before being checked, and each changed the work: + +- `connection:lost` is **not** a notification type — it is `OfflineErrorType` in + `offline-support/types.ts`, a separate taxonomy. Out of scope for the notification union; three core + identifiers were unmapped, not four. +- **Every** core emission site already carried a `type`. `commandUtils.ts:65` appeared un-typed only + because its `type` sits more than eight lines from its `addWarning` call, outside a grep window. +- No eslint override is needed for `src/i18n/**`. `import/no-extraneous-dependencies` permits + `dependencies` outright — the override was only ever required for the rejected peer variant. +- Importing from the `notifications` barrel does **not** drag `NotificationManager` into the i18n + bundle; esbuild already tree-shakes it. A "fix" importing the module directly measured 27 bytes + _worse_ and was reverted. +- Vitest now forces `TZ=UTC`. The date assertions previously passed only on a machine that happened to + be in UTC, which is what CI is — so a local run disagreed with CI by exactly the host's offset. diff --git a/specs/i18n-to-core/plan.md b/specs/i18n-to-core/plan.md new file mode 100644 index 0000000000..ad87f009ec --- /dev/null +++ b/specs/i18n-to-core/plan.md @@ -0,0 +1,96 @@ +# i18n to core — plan + +Four phases. Core is done; the two UI SDK adoptions are not. + +## Phase 0 — Scoped identifiers in core ✅ + +Ships alone, because it is the only irreversible change in the initiative. + +- `CORE_NOTIFICATION_TYPE` + `CoreNotificationType`; every core emission site routed through the map. +- `api:messages:query:failed` / `api:message:query:failed` → `api:message:jump:failed` / + `api:message:jumpToLatest:failed`. +- `Notification.message` documented as a developer-facing fallback, not display copy. +- Poll-composer field errors carry `{ code, message, metadata? }`. +- Drift gates: every declared identifier must be emitted; no raw type literal may appear in `src/`. + +Commit `766b1ddb`. Gate: `yarn lint && yarn types && yarn test-unit --run && yarn build`. + +## Phase 1 — The `stream-chat/i18n` module ✅ + +Based on RN's implementation, which was the later and better of the two. + +- `Streami18n` with a `StateStore`; catalog-generic type helpers; dayjs handling with no module-scope + side effects; three formatters; `getDateString`. +- Generated `languageNames`, the shared notification key registry, `TranslationBuilder` plumbing. +- `stream-chat/i18n` and `stream-chat/i18n/codegen` exports; second and third bundle entries; build-time + boundary assertion. +- `i18next` + `dayjs` as direct dependencies. + +Commits `8828c57e`, `3b573689`, `db7687d0`, `d9cac551`. 2,752 tests; root bundle byte-identical. + +## Phase 2 — `stream-chat-react` adopts + +**Blocker to clear first:** `src/i18n/types.ts:4` imports `MessageContextValue` from `'../context'` and +line 3 imports `Moment` from `moment-timezone`. Both must be cut before the type machinery can move, or +core gains a circular UI dependency and a devDependency type leak. + +- Delete `Streami18n.ts`, the formatter half of `utils.ts`, `TranslationBuilder/TranslationBuilder.ts`, + `externalStrings.ts` (~1,100 lines). +- `types.ts` → ~8-line instantiation of core's generics, intersecting `LanguageNameCatalog`. +- Delete the 57 `language.*` entries from `runtimeDefaults.ts`; merge core's `languageNameDefaults`. Drop + the `asDynamicKey` + string-compare fallback in `MessageTranslationIndicator.tsx:54`. +- Replace `translatorsByNotificationType.ts` with a `Record` — the + unmapped identifiers now fail to compile. +- Move the `Dayjs.extend` calls out of `context/TranslationContext.tsx`. **Highest-risk line in the + diff**: it fails silently, as malformed dates rather than a throw. +- Rewire to `useStateStore`; `useChat.ts:96` keeps its truthiness check. +- Codegen script 249 → ~15 lines. Delete the stale `sideEffects` entry. Drop `i18next`, `dayjs`, + `moment-timezone`. +- **Add a `catalogRenders` test** — React has no equivalent of RN's strongest regression net, and it is + the test most likely to catch a regression from this refactor. +- One-line PR first: add `release-v15` to `size.yml`'s branch filter, or the bundle-size check never + runs on this branch. + +Gate: `yarn build && tsc -p tsconfig.lib.json --noEmit && yarn lint && yarn test && yarn validate-translations`. +Note `yarn types` checks nothing (solution-file `tsconfig.json` with `files: []`). + +## Phase 3 — `stream-chat-react-native` adopts + +Prerequisites, each its own PR: + +1. **Manifests.** `stream-chat` is declared `^9.51.0` in `package/` and both examples, papered over by a + root `resolutions` entry — and **resolutions do not publish**, so a consumer installing the SDK today + gets `stream-chat@9.x` against v10 source. Bump the declared ranges and delete the resolutions entry. + Same PR: the RN 0.79 / Expo 53 baseline (all four ranges). +2. **Replace `instanceof Streami18n`** in `useStreami18n.ts:20` with a brand check plus a + `logger.warn` on the fallback. Today a cross-bundle-copy instance is silently discarded for a fresh + English default — every dictionary, formatter and registered language gone, no warning. RN has three + physical `stream-chat` copies. +3. Add `V10` to `check-pr.yml`'s branch filter, or every gate is manual. + +Then: delete `src/utils/i18n/**`, same type/codegen shrink as React, drop the module-scope +`Dayjs.updateLocale`, add the four `relativeTime.*` keys the translatable relative-compact formatter +needs, replace `useStreami18n` with the `useStateStore` version, migrate +`new Streami18n(opts, i18nextConfig)` call sites, and newly export `Streami18nOptions` and the formatter +types — both unreachable today despite `options.formatters` referencing them. + +Optional, and a real gap: RN renders auto-translated message text +(`useTranslatedMessage.ts:24`) with no indicator of the source language, because it has no equivalent of +React's `MessageTranslationIndicator`. `languageNameDefaults` makes the data one merge away; the +component is a small feature to scope explicitly rather than assume. + +## Cross-repo validation + +Use a `yarn pack` tarball, not `link:`/`portal:`. `enableScripts: false` in all three repos means +`link:` never runs core's `prepare`, so a stale `dist/` gets validated — and they read the live +`package.json`, so they cannot prove the subpath actually shipped, which is the thing under test. + +`git diff --exit-code -- package.json yarn.lock` as an explicit exit gate. Precedent: RN commit +`9dc3f5f6f` committed `portal:/Users/isekovanic/Projects/stream-chat-js`, which resolved on one machine +and failed 399 tests everywhere else. + +End-to-end proof is `examples/SampleApp/src/i18n/` (German + Italian + switcher). Assert in order, +because each isolates a different failure: copy switches → registration and resolution work; month/day +names switch → the locale import reached _core's_ dayjs instance, not a second copy; relative dates read +"Gestern" not "Last Mittwoch" → the calendar plugin was extended on core's dayjs and the per-key +`calendarFormats` still lands. diff --git a/specs/i18n-to-core/spec.md b/specs/i18n-to-core/spec.md new file mode 100644 index 0000000000..d8db0ca8f7 --- /dev/null +++ b/specs/i18n-to-core/spec.md @@ -0,0 +1,82 @@ +# i18n to core — one translation runtime for both UI SDKs + +Status: **core landed** (2026-08). Scope: `stream-chat-js` (this initiative), then +`stream-chat-react` and `stream-chat-react-native` adopt. + +## Why + +`stream-chat-react` (v15) and `stream-chat-react-native` (v10) independently converged on the same +i18n architecture — English-only bundle, stable dotted keys with the English copy inline as i18next's +`defaultValue`, a generated type-only key catalog, and a drift gate on it. Two separate ports: + +- React: `17c91bc70 feat(i18n): english-only bundle with namespaced, type-checked translation keys (#3261)` (305 files, +9865/−11234) +- RN: `a26851d79 feat(i18n): ship English only and rework the public i18n surface` (+683/−7447) + +That left ~1,300–1,500 lines of near-duplicate **runtime** in two repos, plus a duplicated codegen +toolchain that differed by five lines in one file and ~60 in the other. `stream-chat` had no +localization code at all. All three packages were shipping breaking releases, which made it the only +cheap window to move the shared layer down. + +The sharper motivation was not the duplication itself but what the duplication was working around: +**core emitted user-facing English prose, and both SDKs reverse-mapped it by exact string match** to +resolve a translation. Both carried the same comment — _"Renaming the notification messages at the +source needs a `stream-chat` change; until then this table is the seam"_. + +## What was actually wrong + +Investigation found the mechanism already existed and was mostly working, which changed the shape of +the work from greenfield to typing and gap-filling: + +- `Notification.type` already carried a `domain:entity:operation:result` identifier on every core + emission site. Its JSDoc documented a field named `code`, which does not exist — that mislabelling is + why the mechanism looked absent. +- Because `type` was a bare `string`, **both SDKs hand-maintained the same 16-entry `type → key` table** + and the copies had drifted in both directions: entries for identifiers nothing emits + (`api:reply:search:failed`, mapped by both, emitted by neither), and core identifiers neither mapped + (falling through to the English-string fallback, which is why React's map contained + `'Command not ready to be sent'`). +- Core's own naming had drifted too: `api:messages:query:failed` and `api:message:query:failed` were + two different operations distinguished only by a plural `s`, in the counterintuitive direction. +- **Poll-composer field errors had no identifier at all** — plain English in a `Record`. + That is the one place core genuinely emitted unkeyed prose. + +## Shipped + +- **`stream-chat/i18n` subpath** — `Streami18n` (reactive via `StateStore`), three formatters, + `getDateString`, catalog-generic type helpers, `TranslationBuilder` plumbing, generated language + names, the shared notification key registry. +- **`stream-chat/i18n/codegen` subpath** — the catalog generator, Node-only and ESM-only, with `typescript` + injected rather than imported. Verified to reproduce both SDKs' real committed catalogs identically + (React 634/634, RN 408/408 + 97 bundled). +- **Scoped identifiers** — `CORE_NOTIFICATION_TYPE` / `CoreNotificationType` and + `POLL_COMPOSER_VALIDATION_CODE` / `PollComposerValidationError`, both exhaustiveness-checked. +- `i18next` and `dayjs` as direct dependencies of `stream-chat`. + +Consumer-facing delta: `v9-to-v10-migration-guide-i18n.md`. + +## Invariants the implementation has to hold + +Three behavioural guarantees, ported from RN's `Streami18nGuarantees.test.ts` where each was written +against a real bug found reviewing the web implementation. They are core's acceptance contract now, so +a third SDK cannot regress them and neither UI SDK has to keep a copy: + +- **G1** — the SDK's bundled defaults are layered under _every_ language, however it was selected. If + not, a formatter key renders as its own dotted path and a timestamp as an unformatted ISO string. +- **G2** — a partial dictionary is safe: an unsupplied key renders English, never a raw dotted path. + This includes not letting an integrator's `parseMissingKeyHandler` blank out prose keys, since + i18next counts every prose key as "missing". +- **G3** — selecting an unregistered language warns and continues. It must not silently reset to `en`, + which discards the integrator's choice and makes the cause very hard to see. + +Two structural invariants enforced by the build rather than by review, because both fail invisibly: + +- The **root bundle must not reach `src/i18n/`** or its dependencies. Asserted from esbuild's metafile; + `dist/esm/index.mjs` is byte-identical at 907,599 bytes. +- The **runtime i18n layer must not reach `codegen/`**, which is Node-only build tooling living + outside `src/`. + +## Not in scope here + +The catalogs themselves. Each UI SDK generates `keys.ts` from its own `t()` call sites, so they stay +upstream — which is what forced core's type helpers to be generic over the catalog rather than +augmentation-based (two catalogs must be able to coexist in one TypeScript program). diff --git a/specs/i18n-to-core/state.json b/specs/i18n-to-core/state.json new file mode 100644 index 0000000000..78269cf91e --- /dev/null +++ b/specs/i18n-to-core/state.json @@ -0,0 +1,25 @@ +{ + "active_task": "Blocked on publishing stream-chat 10.0.0-rc.3", + "tasks": { + "Phase 0 \u2014 scoped identifiers in core": "done", + "Phase 1 \u2014 the stream-chat/i18n module": "done", + "Phase 1 \u2014 migration guide and initiative record": "done", + "Phase 2 \u2014 stream-chat-react adopts": "done", + "Phase 3 \u2014 stream-chat-react-native adopts": "done" + }, + "flags": { + "blocked": true, + "needs-review": false + }, + "notes": [ + "All three branches are committed and green on their own gates. Nothing can merge until stream-chat 10.0.0-rc.3 publishes from feat/i18n-core-module: both UI SDKs declare ^10.0.0-rc.3, RN deleted the root `resolutions` override that was masking it, and neither lockfile can be regenerated until the version exists. `yarn install --immutable` fails in both consumers today, which also means RN's pre-commit hook cannot run (every yarn command fails at resolution) -- its commits used --no-verify with prettier, binaries and commitlint checked by hand.", + "Naming reversed late: core is `Streami18n`, not `StreamI18n`, and no `@deprecated` remains anywhere in any of the three i18n surfaces. `getTranslators()` (alias for `init()`) and `relativeCompactDateFormatter` (alias for timestampFormatter with relativeCompact) were removed outright rather than carried. Recorded in decisions.md; the rule is now stated in RN's AGENTS.md so it is not reintroduced.", + "Packaging verified against the packed tarball from a clean npm install with no separate i18next/dayjs: all 19 exports-map targets exist, all four conditions resolve, `Streami18n` initializes and renders, the codegen subpath loads, and the root barrel does not expose it. Node's `import` resolves via the `node` condition to CJS, so its namespace carries `default` + `module.exports` -- expected, same shape as the root entry.", + "React's `yarn build` is RED on 3 pre-existing errors unrelated to i18n: APIErrorResponse / EventAPIResponse were removed from core by 5073c676 after rc.2 published. The vite/translations/styling steps pass; only the tsc step fails. React must fix those 3 imports before its branch can build.", + "RN has a 38-suite / 289-test unit baseline and 117 example-app typecheck errors, both pre-existing and unrelated: the mock builders spy on `client.axiosInstance` but core routes most endpoints through its generated OpenAPI client since 0776bc46 (shipped in rc.1). Measured with the i18n port stashed and unstashed -- identical failing set. Must be fixed before v10 ships; not part of this initiative.", + "Not done, and each needs something this environment cannot provide: Metro resolution of stream-chat/i18n from a real RN 0.79 fixture and from ExpoMessaging on SDK 53; the SampleApp end-to-end language-switch proof (drawer -> tap name 7x -> Language -> Deutsch, then the four ordered assertions in the plan); and the published-package size diff, now that React's size.yml and RN's sdk-size-metrics.yml both run on their release branches.", + "Adopting in React found seven core defects, adopting in RN found four more. All fixed with regression tests. None would have been caught by types alone and all rendered wrong rather than throwing -- running each UI SDK's existing suite against the shared layer is what found them.", + "Open scope decision: RN still has no MessageTranslationIndicator, so it renders auto-translated text with no signal and no original/translated toggle. `language.*` is wired in and compile-checked now, so only the component is missing." + ], + "last_updated": "2026-08-18" +} diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts new file mode 100644 index 0000000000..f7c7c6fc6e --- /dev/null +++ b/src/i18n/Streami18n.ts @@ -0,0 +1,528 @@ +import i18next from 'i18next'; +import type { i18n as I18nInstance, InitOptions } from 'i18next'; + +import { StateStore } from '../store'; +import { + addOrUpdateDayjsLocale, + dayjsLocaleExists, + ensureDayjsPlugins, + getDefaultDateTimeParserModule, + isDayjsLike, + supportsTimezone, +} from './dayjs'; +import type { DayjsLocaleConfig } from './dayjs'; +import { predefinedFormatters } from './formatters'; +import { TranslationBuilder } from './TranslationBuilder'; +import type { TranslationTopicConstructor } from './TranslationBuilder'; +import { DEFAULT_LANGUAGE, TranslationStore } from './TranslationStore'; +import { + asDynamicKey, + createDefaultTranslatorFunction, + guardMissingKeyHandler, +} from './translator'; +import type { + AnyTranslationCatalog, + CustomFormatters, + DateTimeParserModule, + FormatterContext, + LooseTranslateFunction, + PredefinedFormatters, + StreamTFunctionFor, + TDateTimeParser, + TranslationDictionaryOf, +} from './types'; + +const DEFAULT_NAMESPACE = 'translation'; + +export type Streami18nOptions = { + /** A dayjs or moment module. Defaults to dayjs with the required plugins registered. */ + DateTimeParser?: DateTimeParserModule; + dayjsLocaleConfigForLanguage?: DayjsLocaleConfig; + debug?: boolean; + /** Keep dates in English regardless of the active language. */ + disableDateTimeTranslations?: boolean; + formatters?: Partial & CustomFormatters; + /** + * Any i18next `InitOptions`. Applied over the SDK's defaults, so it can reach settings the SDK does + * not surface. `parseMissingKeyHandler` supplied here is guarded the same way as the top-level + * option. + */ + i18nextConfigOverrides?: Partial; + language?: string; + logger?: (message?: string) => void; + /** + * Called only for keys that are genuinely missing — one with no inline default and no bundled + * value. See {@link guardMissingKeyHandler} for why it cannot be passed straight to i18next. + */ + parseMissingKeyHandler?: (key: string, defaultValue?: string) => string; + /** + * The SDK's bundled translation data: the keys that cannot carry an inline `defaultValue` at their + * call site. + * + * Injected rather than imported because the catalog belongs to the UI SDK, not to core. Layered + * under **every** language, which is what stops a partial dictionary from knocking out formatter + * keys. + */ + runtimeDefaults?: Record; + /** A valid TZ identifier, e.g. `Europe/Prague`. */ + timezone?: string; + /** + * Post-processor topics for copy that cannot be resolved from a key alone — see + * {@link TranslationBuilder}. The key here must match the post-processor name in the translation + * value, i.e. `{{ value, topicName }}`. + */ + translationBuilderTopics?: Record; + translationsForLanguage?: TranslationDictionaryOf; +}; + +export type Streami18nState< + C extends AnyTranslationCatalog = AnyTranslationCatalog, + Bundled extends string = never, +> = { + initialized: boolean; + language: string; + t: StreamTFunctionFor; + tDateTimeParser: TDateTimeParser; +}; + +/** + * Wrapper around [i18next](https://www.i18next.com/) for Stream's translations. A UI SDK passes an + * instance to its `` component to control language and copy. Only English ships; every other + * language comes from the integrator, and a partial dictionary is safe. + * + * ```ts + * import 'dayjs/locale/de'; + * + * const i18n = new Streami18n({ language: 'de' }); + * i18n.registerTranslation('de', de, { calendar: { sameDay: '[heute um] LT', ... } }); + * ``` + * + * No dayjs locale file defines `calendar` — that field belongs to the calendar plugin — so a new + * language needs both the locale import and a `calendar` config, or relative dates render English + * scaffolding around translated day names. + */ +export class Streami18n< + C extends AnyTranslationCatalog = AnyTranslationCatalog, + Bundled extends string = never, +> { + /** Marks instances across bundle copies, where `instanceof` silently fails. */ + static readonly brand = Symbol.for('stream-chat.Streami18n'); + + readonly i18nInstance: I18nInstance = i18next.createInstance(); + + readonly state: StateStore>; + + readonly translationBuilder: TranslationBuilder; + + readonly logger: (message?: string) => void; + readonly DateTimeParser: DateTimeParserModule; + readonly formatters: PredefinedFormatters & CustomFormatters; + readonly timezone?: string; + + private readonly translations: TranslationStore; + + /** Applied when the language becomes active, not on registration: `Dayjs.locale()` is global. */ + private readonly dayjsLocales: Record = {}; + + private readonly translationBuilderTopics: Record; + private readonly disableDateTimeTranslations: boolean; + private readonly i18nextConfig: InitOptions; + private initPromise?: Promise>; + /** Set by {@link overrideTFunction}, so `init()` does not clobber a swapped-in implementation. */ + private tOverridden = false; + + constructor(options: Streami18nOptions = {}) { + this.logger = options.logger ?? ((message?: string) => console.warn(message)); + this.translations = new TranslationStore(options.runtimeDefaults); + this.disableDateTimeTranslations = options.disableDateTimeTranslations ?? false; + this.timezone = options.timezone; + this.formatters = { ...predefinedFormatters, ...options.formatters }; + this.translationBuilder = new TranslationBuilder(this.i18nInstance); + this.translationBuilderTopics = options.translationBuilderTopics ?? {}; + + const language = options.language ?? DEFAULT_LANGUAGE; + + if (options.DateTimeParser) { + this.DateTimeParser = options.DateTimeParser; + // The supplied module, not ours -- it may be a second copy of dayjs, and extending ours would + // leave theirs plugin-less, rendering every `LT` / `LLLL` token literally. + if (isDayjsLike(this.DateTimeParser)) ensureDayjsPlugins(this.DateTimeParser); + } else { + this.DateTimeParser = getDefaultDateTimeParserModule(); + } + + const tDateTimeParser: TDateTimeParser = (timestamp) => { + const locale = + this.disableDateTimeTranslations || !this.localeExists(this.currentLanguageValue) + ? DEFAULT_LANGUAGE + : this.currentLanguageValue; + + const parsed = this.DateTimeParser(timestamp); + const withZone = + this.timezone && supportsTimezone(this.DateTimeParser) + ? (parsed as unknown as { tz: (tz: string) => typeof parsed }).tz(this.timezone) + : parsed; + + return (withZone as unknown as { locale: (l: string) => typeof parsed }).locale( + locale, + ); + }; + + this.state = new StateStore>({ + initialized: false, + language, + t: createDefaultTranslatorFunction(), + tDateTimeParser, + }); + + // Both always exist, so an unregistered language still renders English copy rather than dotted keys. + this.translations.ensure(DEFAULT_LANGUAGE); + this.translations.ensure(language); + + if (options.translationsForLanguage) { + this.translations.register( + language, + options.translationsForLanguage as Record, + ); + } + + const missingKeyHandler = + options.parseMissingKeyHandler ?? + options.i18nextConfigOverrides?.parseMissingKeyHandler; + + this.i18nextConfig = { + debug: options.debug ?? false, + fallbackLng: false, + interpolation: { escapeValue: false, formatSeparator: '|' }, + // Must stay false: keys are flat strings containing dots, and some copy contains `...`. + keySeparator: false, + lng: language, + nsSeparator: false, + // i18next only runs post-processors it was told about at init time. + ...(Object.keys(this.translationBuilderTopics).length > 0 + ? { postProcess: Object.keys(this.translationBuilderTopics) } + : {}), + ...options.i18nextConfigOverrides, + // Guarded even when integrator-supplied: an unguarded handler silently blanks every prose key. + parseMissingKeyHandler: missingKeyHandler + ? guardMissingKeyHandler(missingKeyHandler) + : (key: string, defaultValue?: string) => { + if (typeof defaultValue === 'string') return defaultValue; + this.logger(`Streami18n: missing translation for key: ${key}`); + return key; + }, + }; + + // No dictionary check here -- `registerTranslation()` legitimately runs after construction, so + // `init()` is the first moment the registered set is final. + if (options.dayjsLocaleConfigForLanguage) { + this.addOrUpdateLocale(language, options.dayjsLocaleConfigForLanguage); + } else if (!this.localeExists(language)) { + this.logger( + `Streami18n: no dayjs locale is registered for '${language}', so dates render with the ` + + `English locale. Import it with "import 'dayjs/locale/${language}';" in your app, or pass ` + + `a config via registerTranslation('${language}', translation, dayjsLocaleConfig).`, + ); + } + } + + get t(): StreamTFunctionFor { + return this.state.getLatestValue().t; + } + + get tDateTimeParser(): TDateTimeParser { + return this.state.getLatestValue().tDateTimeParser; + } + + get currentLanguage(): string { + return this.state.getLatestValue().language; + } + + get initialized(): boolean { + return this.state.getLatestValue().initialized; + } + + /** Read inside the constructor, before `state` getters are safe to rely on externally. */ + private get currentLanguageValue(): string { + return this.state?.getLatestValue().language ?? DEFAULT_LANGUAGE; + } + + /** + * Initializes i18next. Idempotent and safe to call concurrently. + * + * Memoized, so two independent consumers — a UI SDK's chat root and its overlay host — share one + * initialization. + * + * An i18next failure does **not** reject: neither UI SDK awaits this, so a rejection would surface + * as an unhandled rejection and the memo would latch it for the process lifetime. It is logged + * instead, leaving the instance *degraded but safe*: `state.initialized` stays false, which is what + * keeps the methods below off a dead i18next instance, and `t` remains the default translator, so + * every call site still renders its inline English. There is no retry — construct a new instance. + * + * One path does escape: an integrator `logger` that throws is called from the `catch` itself, so it + * rejects out of here. Rare enough not to guard, but it is why this is not an absolute guarantee. + */ + init(): Promise> { + this.initPromise ??= this.runInit(); + return this.initPromise; + } + + private async runInit(): Promise> { + // Everything is inside the `try` -- see `init()` for why an i18next failure must not reject. + try { + this.validateCurrentLanguage(); + this.assertPluralRulesCoverage(this.currentLanguage); + + const dayjsLocale = this.dayjsLocales[this.currentLanguage]; + if (dayjsLocale) this.addOrUpdateLocale(this.currentLanguage, dayjsLocale); + + const t = await this.i18nInstance.init({ + ...this.i18nextConfig, + lng: this.currentLanguage, + resources: this.i18nextResources(), + }); + + this.registerFormatters(); + + // After init, so post-processors attach to a live instance and buffered translators flush. + Object.entries(this.translationBuilderTopics).forEach(([topic, Topic]) => { + this.translationBuilder.registerTopic(topic, Topic); + }); + + this.state.partialNext({ + initialized: true, + ...(this.tOverridden + ? {} + : { t: t as unknown as StreamTFunctionFor }), + }); + } catch (error) { + this.logger(`Streami18n: initialization failed: ${describeError(error)}`); + } + + return this.state.getLatestValue(); + } + + /** + * Re-run on every language change, not just at `init()`: factories destructure the language out of + * their context, so one built once keeps formatting in the initial language forever. `formatter.add` + * replaces by name, which is what makes re-registering sufficient. + */ + private registerFormatters = () => { + const context = this.createFormatterContext(); + + Object.entries(this.formatters).forEach(([name, factory]) => { + if (!factory) return; + const formatter = factory(context); + // Widened here rather than in the public type: a custom formatter's value is `never` so any + // implementation is assignable (parameters are contravariant), while i18next's takes `any`. + this.i18nInstance.services.formatter?.add( + name, + formatter as (value: any, lng: string | undefined, options: any) => string, + ); + }); + }; + + /** + * Accessors rather than snapshots, for a formatter that holds the context and reads per call; one + * that destructures is covered by {@link registerFormatters} re-running instead. + * + * The nested arrows are load-bearing: a getter in an object literal binds `this` to the literal. + */ + private createFormatterContext = (): FormatterContext => { + const readLanguage = () => this.currentLanguage; + const readDateTimeParser = () => this.tDateTimeParser; + + return { + get currentLanguage() { + return readLanguage(); + }, + dateTimeParser: this.DateTimeParser, + logger: this.logger, + get tDateTimeParser() { + return readDateTimeParser(); + }, + timezone: this.timezone, + translate: this.translate, + }; + }; + + /** The store's flat dictionaries in i18next's nested `resources` shape. */ + private i18nextResources = (): Record>> => + Object.fromEntries( + this.translations + .entries() + .map(([language, dictionary]) => [language, { [DEFAULT_NAMESPACE]: dictionary }]), + ); + + /** The only route for a language added after `init()`. */ + private ensureLanguage = (language: string) => { + this.addResources(language, this.translations.ensure(language)); + }; + + private addResources = (language: string, dictionary: Record) => { + if (!this.initialized) return; + this.i18nInstance.addResources(language, DEFAULT_NAMESPACE, dictionary); + }; + + registerTranslation( + language: string, + translation: TranslationDictionaryOf, + dayjsLocaleConfig?: DayjsLocaleConfig, + ) { + if (!translation) { + this.logger( + 'Streami18n: registerTranslation called without a translation dictionary', + ); + return; + } + + const merged = this.translations.register( + language, + translation as Record, + ); + + if (dayjsLocaleConfig) { + this.dayjsLocales[language] = { ...dayjsLocaleConfig }; + } else if (!this.localeExists(language)) { + this.logger( + `Streami18n: no dayjs locale is registered for '${language}'. Import it with ` + + `"import 'dayjs/locale/${language}';" in your app, or pass a config as the third ` + + `argument to registerTranslation.`, + ); + } + + // `merged`, not `translation`: for a post-init language this is the only write into i18next's + // store, so the partial would leave the bundled defaults absent there. + this.addResources(language, merged); + } + + /** + * Returns nothing: the new `t` is published to {@link Streami18n.state}. Handing one back would offer + * a value that goes stale on the next language change. + */ + async setLanguage(language: string): Promise { + const previousLanguage = this.state.getLatestValue().language; + + // Published up front so the warnings below name the language being adopted, and rolled back in the + // `catch` -- otherwise the store advertises one i18next never switched to. + this.state.partialNext({ language }); + this.ensureLanguage(language); + + if (!this.initialized) return; + + this.validateCurrentLanguage(); + this.assertPluralRulesCoverage(language); + + try { + const t = await this.i18nInstance.changeLanguage(language); + const dayjsLocale = this.dayjsLocales[language]; + if (dayjsLocale) this.addOrUpdateLocale(language, dayjsLocale); + this.registerFormatters(); + if (!this.tOverridden) { + this.state.partialNext({ t: t as unknown as StreamTFunctionFor }); + } + } catch (error) { + this.state.partialNext({ language: previousLanguage }); + this.logger(`Streami18n: failed to set language: ${describeError(error)}`); + } + } + + /** + * Swaps in a different translation implementation, for an app that already has an i18n layer. Safe + * before `init()`, which will not overwrite it. + */ + overrideTFunction(t: StreamTFunctionFor) { + this.tOverridden = true; + this.state.partialNext({ t }); + } + + /** + * Languages an integrator supplied a dictionary for. Read-only because adding to it would claim a + * language is registered with no dictionary behind it; use `registerTranslation`. + */ + get registeredLanguages(): ReadonlySet { + return this.translations.registeredLanguages; + } + + /** + * Warns rather than falling back to `en`: the language renders English copy from the inline defaults + * while keeping its own date formats, and resetting it would discard the integrator's choice. + */ + private validateCurrentLanguage = () => { + const language = this.currentLanguageValue; + if (this.translations.isRegistered(language)) return; + + this.logger( + `Streami18n: no translation dictionary is registered for '${language}', so the SDK's copy ` + + `renders in English. Call registerTranslation('${language}', {...}) to translate it. ` + + `Registered: ${[...this.translations.registeredLanguages].join(', ')}`, + ); + }; + + /** + * Checked against the module that actually formats the dates. + * + * A supplied dayjs copy has its own locale registry, so consulting ours would answer for the wrong + * one. True for a non-dayjs parser, whose registry we cannot inspect. + */ + private localeExists = (language: string) => { + if (!isDayjsLike(this.DateTimeParser)) return true; + return dayjsLocaleExists(language, this.DateTimeParser); + }; + + /** + * Registers a locale on the module that formats the dates, not on ours. + * + * Only dayjs has a registry we can write to. For a Moment the config cannot be applied at all, so it + * is reported -- previously it was written to core's own dayjs, where nothing would ever read it. + */ + private addOrUpdateLocale(language: string, config: DayjsLocaleConfig) { + if (!isDayjsLike(this.DateTimeParser)) { + this.logger( + `Streami18n: a dayjs locale config was supplied for '${language}', but DateTimeParser is ` + + `not dayjs, so it cannot be applied. Configure the locale on your own date library instead.`, + ); + return; + } + addOrUpdateDayjsLocale(language, config, this.DateTimeParser); + } + + /** + * For formatters, which resolve keys handed to them at runtime -- including their own + * `relativeTime.*` copy, which no catalog declares. + */ + private translate: LooseTranslateFunction = (key, defaultValueOrOptions, options) => + (this.t as LooseTranslateFunction)( + asDynamicKey(key), + defaultValueOrOptions, + options, + ) as string; + + /** + * Hermes ships a partial ICU: `Intl.PluralRules` silently falls back to root rules (`other` only) for + * locales it lacks, so a dictionary's `_few` / `_many` never render and nothing errors. + * + * Called from `init()` because i18next caches a resolver per language there — the last moment + * `intl-pluralrules` could still have been loaded in time. + */ + private assertPluralRulesCoverage = (language: string) => { + try { + const resolved = new Intl.PluralRules(language).resolvedOptions().locale; + if (resolved.split('-')[0] === language.split('-')[0]) return; + this.logger( + `Streami18n: Intl.PluralRules has no data for '${language}' (it resolved to ` + + `'${resolved}'), so every count selects the '_other' form. On React Native, import ` + + `'intl-pluralrules' before anything else in your entry file.`, + ); + } catch { + this.logger( + `Streami18n: Intl.PluralRules is unavailable, so plural selection will not work. On React ` + + `Native, import 'intl-pluralrules' before anything else in your entry file.`, + ); + } + }; +} + +/** `JSON.stringify(error)` renders an `Error` as `{}`. */ +const describeError = (error: unknown) => + error instanceof Error ? error.message : String(error); diff --git a/src/i18n/TranslationBuilder.ts b/src/i18n/TranslationBuilder.ts new file mode 100644 index 0000000000..1ed8bc0cb8 --- /dev/null +++ b/src/i18n/TranslationBuilder.ts @@ -0,0 +1,176 @@ +import type { i18n as I18nInstance } from 'i18next'; + +/** + * An i18next instance, as accepted by {@link TranslationTopic} and exposed as + * `Streami18n.i18nInstance`. + * + * Re-exported because it is part of this module's public surface: a consumer implementing a topic, or + * mocking one in a test, has to be able to name the type. Without this they would reach past + * `stream-chat` into `i18next` directly and have to declare it themselves — the same mistake the + * `moment-timezone` type leak was. + */ +export type { I18nInstance }; + +import type { LooseTranslateFunction } from './types'; + +/** + * i18next post-processor plumbing, for copy that cannot be resolved from a key alone. + * + * The motivating case is a notification: what to render depends on a runtime object, not just the key, + * so `t('translationBuilderTopic.notification', { notification })` dispatches through a *topic* which + * picks a *translator* based on that object. This is only the mechanism — the topics and their + * translators are SDK-specific and stay in the UI SDKs, since they reference SDK key names. + * + * A UI SDK may not need this at all: dispatching on the object at the render site instead is perfectly + * valid, and the React Native SDK does exactly that. The plumbing lives here so either approach is + * available without a core change. + */ +type TopicName = string; +type TranslatorName = string; + +/** + * Resolves one case within a topic. Returning `null` means "not mine" and lets the next candidate try. + * + * `t` is loose rather than catalog-typed: a translator is handed keys by the post-processor at runtime, + * so it cannot be checked against a specific catalog. + */ +export type Translator = Record> = + (params: { + key: string; + options: O; + t: LooseTranslateFunction; + value: string; + }) => string | null; + +export type TranslationTopicOptions< + O extends Record = Record, +> = { + i18next: I18nInstance; + translators?: Record>; +}; + +export abstract class TranslationTopic< + O extends Record = Record, +> { + protected translators: Map> = new Map(); + protected i18next: I18nInstance; + + constructor(protected options: TranslationTopicOptions) { + this.i18next = options.i18next; + if (options.translators) { + Object.entries(options.translators).forEach(([name, translator]) => { + this.setTranslator(name, translator); + }); + } + } + + abstract translate(value: string, key: string, options: O): string; + + setTranslator = (name: string, translator: Translator) => { + this.translators.set(name, translator); + }; + + removeTranslator = (name: string) => { + this.translators.delete(name); + }; +} + +export type TranslationTopicConstructor = new ( + options: TranslationTopicOptions, +) => TranslationTopic; + +const forwardTranslation: Translator = ({ value }) => value; + +export class TranslationBuilder { + private topics = new Map(); + + /** + * Translators registered before their topic exists. + * + * Topics are only created during `Streami18n.init()`, but an integrator registers translators against + * the constructed instance — so registrations that arrive first are buffered and flushed when the + * topic appears, rather than silently dropped. + */ + private translatorRegistrationsBuffer: Record< + TopicName, + Record + > = {}; + + constructor(private i18next: I18nInstance) {} + + registerTopic = (name: TopicName, Topic: TranslationTopicConstructor) => { + let topic = this.topics.get(name); + + if (!topic) { + topic = new Topic({ i18next: this.i18next }); + this.topics.set(name, topic); + this.i18next.use({ + name, + process: (value: string, key: string, options: Record) => { + // Re-read from the map rather than closing over `topic`, so `disableTopic` takes effect. + const registered = this.topics.get(name); + if (!registered) return value; + return registered.translate(value, key, options); + }, + type: 'postProcessor' as const, + }); + } + + const buffered = this.translatorRegistrationsBuffer[name]; + if (buffered) { + Object.entries(buffered).forEach(([translatorName, translator]) => { + topic.setTranslator(translatorName, translator); + }); + delete this.translatorRegistrationsBuffer[name]; + } + + return topic; + }; + + disableTopic = (topicName: TopicName) => { + const topic = this.topics.get(topicName); + if (!topic) return; + // i18next has no way to remove a post-processor, so it is replaced with a pass-through. + this.i18next.use({ + name: topicName, + process: forwardTranslation, + type: 'postProcessor', + }); + this.topics.delete(topicName); + }; + + getTopic = (topicName: TopicName) => this.topics.get(topicName); + + registerTranslators( + topicName: TopicName, + translators: Record, + ) { + const topic = this.getTopic(topicName); + + if (!topic) { + this.translatorRegistrationsBuffer[topicName] ??= {}; + Object.entries(translators).forEach(([translatorName, translator]) => { + this.translatorRegistrationsBuffer[topicName][translatorName] = translator; + }); + return; + } + + Object.entries(translators).forEach(([name, translator]) => { + topic.setTranslator(name, translator); + }); + } + + removeTranslators(topicName: TopicName, translators: TranslatorName[]) { + if (this.translatorRegistrationsBuffer[topicName]) { + translators.forEach((translatorName) => { + delete this.translatorRegistrationsBuffer[topicName][translatorName]; + }); + } + + const topic = this.getTopic(topicName); + if (!topic) return; + translators.forEach((name) => { + topic.removeTranslator(name); + }); + } +} diff --git a/src/i18n/TranslationStore.ts b/src/i18n/TranslationStore.ts new file mode 100644 index 0000000000..a4cce6f8cc --- /dev/null +++ b/src/i18n/TranslationStore.ts @@ -0,0 +1,96 @@ +/** + * The translation dictionaries an instance holds, and the one rule that governs them. + * + * Extracted from `Streami18n` because it is a self-contained concern with no dependency on i18next or + * dayjs: given the SDK's bundled defaults and whatever dictionaries an integrator supplies, produce the + * dictionary for a language. That makes the layering rule testable on its own rather than only through + * a fully initialized instance. + * + * Deliberately free of i18next concepts — no namespaces, no resource nesting. `Streami18n` adapts these + * flat dictionaries to i18next's shape, so the rule below stays readable without knowing that library. + */ + +/** The one language whose dictionary always exists, because the bundled copy is English. */ +export const DEFAULT_LANGUAGE = 'en'; + +export class TranslationStore { + /** + * The SDK's bundled translation data: the keys that cannot carry an inline `defaultValue` at their + * call site — formatter expressions, and prose reaching `t()` as a runtime value. + */ + private readonly runtimeDefaults: Record; + + private readonly dictionaries = new Map>(); + + private readonly registered = new Set([DEFAULT_LANGUAGE]); + + constructor(runtimeDefaults: Record = {}) { + this.runtimeDefaults = runtimeDefaults; + } + + /** + * Languages an integrator actually supplied a dictionary for. + * + * Deliberately narrower than {@link TranslationStore.languages}, which also counts every language + * created just to carry the bundled defaults. Without the distinction there would be no way to warn + * that the active language has no translations — every language would look registered. + */ + get registeredLanguages(): ReadonlySet { + return this.registered; + } + + /** Every language with a dictionary, including those carrying only the bundled defaults. */ + get languages(): string[] { + return [...this.dictionaries.keys()]; + } + + /** `language -> dictionary`, for a caller that has to hand them all over at once. */ + entries(): Array<[string, Record]> { + return [...this.dictionaries]; + } + + isRegistered(language: string) { + return this.registered.has(language); + } + + /** + * Guarantees `language` has a dictionary, and returns it. + * + * Called for a language nobody registered, so that it still formats dates and renders the SDK's copy + * in English rather than raw dotted keys. + */ + ensure(language: string): Record { + return this.merge(language); + } + + /** + * Layers a dictionary over what `language` already has, and marks it registered. + * + * **Merged, not replaced.** Repeated calls for one language accumulate, and — the reason this class + * exists — the bundled defaults survive a partial dictionary. A bundled key has no inline + * `defaultValue` at its call site and `fallbackLng` is false, so a language that loses them renders + * raw dotted keys and unformatted ISO timestamps. That is guarantee G1 of the i18n suite. + */ + register(language: string, dictionary: Record): Record { + const merged = this.merge(language, dictionary); + this.registered.add(language); + return merged; + } + + /** + * Bundled defaults first, then whatever the language already had, then the incoming dictionary — so + * an integrator can override a bundled key, and a later registration wins over an earlier one. + */ + private merge( + language: string, + dictionary?: Record, + ): Record { + const merged = { + ...this.runtimeDefaults, + ...this.dictionaries.get(language), + ...dictionary, + }; + this.dictionaries.set(language, merged); + return merged; + } +} diff --git a/src/i18n/dayjs.ts b/src/i18n/dayjs.ts new file mode 100644 index 0000000000..ab2c605d6b --- /dev/null +++ b/src/i18n/dayjs.ts @@ -0,0 +1,228 @@ +import Dayjs from 'dayjs'; +import calendar from 'dayjs/plugin/calendar.js'; +import duration from 'dayjs/plugin/duration.js'; +import localeData from 'dayjs/plugin/localeData.js'; +import localizedFormat from 'dayjs/plugin/localizedFormat.js'; +import relativeTime from 'dayjs/plugin/relativeTime.js'; +import timezone from 'dayjs/plugin/timezone.js'; +import updateLocale from 'dayjs/plugin/updateLocale.js'; +import utc from 'dayjs/plugin/utc.js'; + +import type { + DateTimeLike, + DateTimeParserModule, + TDateTimeParserInput, + TDateTimeParserOutput, +} from './types'; + +/** + * The calendar-plugin config shape. Not part of dayjs's own `ILocale`, so it has to be declared here. + * + * Supplying it is how relative wording ("heute um", "ieri alle") gets localized — no dayjs locale file + * defines `calendar`, which is the single most common surprise when adding a language. + */ +export type CalendarFormats = { + lastDay: string; + lastWeek: string; + nextDay: string; + nextWeek: string; + sameDay: string; + sameElse: string; +}; + +/** + * A dayjs locale config, as accepted by `dayjsLocaleConfigForLanguage` and by + * `registerTranslation`'s third argument. + * + * Typing this as a bare `Partial` makes passing a calendar config a TS2345 "no properties in + * common" error, which is exactly the wording an integrator hits first — hence the explicit + * `calendar`. + */ +export type DayjsLocaleConfig = Partial & { calendar?: CalendarFormats }; + +/** + * The dayjs module surface this file drives: our own `dayjs`, or a module an integrator supplied + * through `DateTimeParser`. Method shorthand, so a real `typeof dayjs` satisfies it -- as function + * properties these would be checked contravariantly and rejected. + * + * `Ls`, `locale` and `updateLocale` are optional because a module only has them once the plugins are + * registered, and because a non-dayjs parser (a Moment) has a different shape entirely. + */ +type DayjsExtendable = object & { + extend?(plugin: unknown, option?: unknown): unknown; + Ls?: Record; + locale?(preset: unknown, object?: unknown, isLocal?: boolean): unknown; + updateLocale?(name: string, config: unknown): unknown; +}; + +/** The module locale helpers default to, when a caller does not name one. */ +const ownDayjs = () => Dayjs as unknown as DayjsExtendable; + +/** + * The English locale skeleton a custom locale is merged over, so a partial config still has month and + * weekday names to fall back on. + */ +const EN_LOCALE_FALLBACK = { + /** + * `formats` and `relativeTime` are empty on purpose, and are not removable: `Dayjs.locale()` takes an + * `ILocale`, which declares both as required, so omitting them is a type error. Empty means "inherit + * dayjs's own defaults", which is the intent. + */ + formats: {}, + relativeTime: {}, + months: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + weekdays: [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], +}; + +/** + * The plugins the formatters depend on, in dependency order: `timezone` builds on `utc`. + */ +const REQUIRED_PLUGINS = [ + updateLocale, + utc, + timezone, + localizedFormat, + calendar, + localeData, + relativeTime, + duration, +]; + +/** + * Modules already extended. A `WeakSet` rather than a boolean because the module to extend is not + * always ours -- see {@link ensureDayjsPlugins}. + */ +const extendedModules = new WeakSet(); + +/** + * Registers the dayjs plugins the formatters need, once per module. + * + * Takes the module to extend, defaulting to our own `dayjs`. Passing it matters: an integrator + * supplying `DateTimeParser` may hand over a *different physical copy* of dayjs, and extending ours + * leaves theirs without the plugins. That failure is silent and total -- `.calendar()` is simply + * absent, and `format('LT')` returns the literal string `"LT"` because `localizedFormat` never + * registered the token. + * + * Deliberately **not** done at module scope. Module-scope `Dayjs.extend(...)` is a side effect, which + * would force `stream-chat` to declare `sideEffects` and would make importing this module do work + * whether or not anything uses it. Calling it from both the constructor and `defaultDateTimeParser` + * covers the two ways the formatters can be reached, including a standalone `getDateString()` call + * with no `Streami18n` instance in play. + * + * Idempotent twice over: tracked here, and dayjs itself no-ops a repeated `extend` via the plugin's + * `$i` marker. The module is recorded *after* the extends run, so a throw does not leave it marked as + * done. + * + * `timezone` is included because it depends on `utc` and callers can set `timezone` at any point; + * registering it lazily on first use would leave the plugin missing for an instance that only sets + * `timezone` later. + */ +export const ensureDayjsPlugins = ( + module: DayjsExtendable = Dayjs as unknown as DayjsExtendable, +) => { + if (extendedModules.has(module)) return; + if (typeof module.extend !== 'function') return; + + for (const plugin of REQUIRED_PLUGINS) module.extend(plugin); + extendedModules.add(module); +}; + +/** + * The parser used when none is supplied, and by `getDateString()` called outside an instance. + * + * Note there is no `import 'dayjs/locale/en'` anywhere: dayjs bundles `en` and has it registered + * before any import runs (`Object.keys(Dayjs.Ls)` is already `['en']`), so that import — which both UI + * SDKs carried — was a no-op. + */ +export const defaultDateTimeParser = (input?: TDateTimeParserInput) => { + ensureDayjsPlugins(); + return Dayjs(input); +}; + +/** + * The dayjs module itself, with plugins registered. + * + * `Streami18n.DateTimeParser` has to be the *module*, not a parse function, because + * `durationFormatter` calls `.duration()` — which lives on the module, not on a parsed instance. + */ +export const getDefaultDateTimeParserModule = (): DateTimeParserModule => { + ensureDayjsPlugins(); + return Dayjs as unknown as DateTimeParserModule; +}; + +/** + * Registers or updates a dayjs locale without changing the global locale. + * + * Takes the module to register on, defaulting to ours. Passing it matters for the same reason + * {@link ensureDayjsPlugins} takes one: an integrator supplying `DateTimeParser` may hand over a second + * physical copy of dayjs, and registering on ours would leave the locale absent from the module that + * actually formats the dates -- so a `calendar` config or a `dayjsLocaleConfigForLanguage` would be + * silently ignored. + */ +export const addOrUpdateDayjsLocale = ( + language: string, + config: DayjsLocaleConfig, + module: DayjsExtendable = ownDayjs(), +) => { + ensureDayjsPlugins(module); + + if (dayjsLocaleExists(language, module)) { + module.updateLocale?.(language, { ...config }); + return; + } + // Merged over the English skeleton so missing keys still resolve. + module.locale?.({ name: language, ...EN_LOCALE_FALLBACK, ...config }, undefined, true); +}; + +export const dayjsLocaleExists = ( + language: string, + module: DayjsExtendable = ownDayjs(), +) => Object.keys(module.Ls ?? {}).includes(language); + +/** + * Whether a parser is dayjs, as opposed to a Moment the integrator brought. + * + * A property check rather than the `.extend !== undefined` both UI SDKs used, which throws on `null`. + */ +export const isDayjsLike = (parser: unknown): parser is DateTimeParserModule => + typeof parser === 'function' && + typeof (parser as DateTimeParserModule).extend === 'function'; + +/** Whether a parser supports `.tz()`, i.e. dayjs with the timezone plugin, or moment-timezone. */ +export const supportsTimezone = (parser: unknown): boolean => + typeof parser === 'function' && typeof (parser as { tz?: unknown }).tz === 'function'; + +export const isDate = (value: TDateTimeParserOutput): value is Date => + value instanceof Date; + +export const isNumberOrString = ( + value: TDateTimeParserOutput, +): value is number | string => typeof value === 'number' || typeof value === 'string'; + +/** Whether a parser output is a dayjs or Moment object rather than a raw Date/string/number. */ +export const isDayOrMoment = (value: TDateTimeParserOutput): value is DateTimeLike => + typeof value === 'object' && + value !== null && + !(value instanceof Date) && + typeof (value as DateTimeLike).format === 'function'; diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts new file mode 100644 index 0000000000..e5aa537675 --- /dev/null +++ b/src/i18n/formatters.ts @@ -0,0 +1,461 @@ +import { isDate, isDayOrMoment, isNumberOrString } from './dayjs'; +import type { CalendarFormats } from './dayjs'; +import { asDynamicKey } from './translator'; +import type { + DurationFormatterOptions, + FormatterContext, + FormatterFactory, + LooseTranslateFunction, + PredefinedFormatters, + TDateTimeParser, + TimestampFormatterOptions, +} from './types'; + +/** + * The `relativeTime.*` keys this module renders, with their English copy. + * + * Exported as a catalog fragment because the *call sites* are here, in core, while the *catalog* is + * generated from each UI SDK's own source. Without this an SDK's codegen cannot see these keys, so they + * would drop out of its `TranslationCatalog` and an integrator could no longer type them in a + * dictionary — i.e. could no longer translate relative dates at all. A UI SDK intersects this into its + * catalog type. + * + * Plurals appear as `_one` / `_other` because that is how a dictionary supplies them; English needs no + * distinction, but a language with different forms does. + */ +export const RELATIVE_TIME_CATALOG = { + 'relativeTime.daysAgo_one': '{{ count }}d ago', + 'relativeTime.daysAgo_other': '{{ count }}d ago', + 'relativeTime.today': 'Today', + 'relativeTime.weeksAgo_one': '{{ count }}w ago', + 'relativeTime.weeksAgo_other': '{{ count }}w ago', + 'relativeTime.yesterday': 'Yesterday', +} as const; + +export type RelativeTimeCatalog = typeof RELATIVE_TIME_CATALOG; + +/** Defaults for the relative-compact window, matching what both UI SDKs shipped. */ +const DEFAULT_RELATIVE_COMPACT_MAX_DAYS = 6; +const DEFAULT_RELATIVE_COMPACT_MAX_WEEKS = 3; + +/** + * Whether a string is not a date this module can render. + * + * `!Date.parse(value)` would be the obvious spelling and is wrong: `Date.parse` returns `0` for the + * Unix epoch, which is falsy, so a perfectly valid `'1970-01-01T00:00:00.000Z'` was classified as junk + * and dropped -- rendered as `''` by the formatter and as `null` by `getDateString`. + */ +const isUnparseableDateString = (value: string) => Number.isNaN(Date.parse(value)); + +/** + * Coerces the week-rounding option, which arrives as text from an i18next format expression. + * + * Anything unrecognised falls back to `floor`, so a typo degrades to the default rather than throwing + * inside a formatter, where the only visible symptom would be a blank timestamp. + */ +const asWeekRounding = (value: unknown): 'ceil' | 'floor' => + value === 'ceil' || value === true ? 'ceil' : 'floor'; + +/** + * Coerces a numeric formatter option. + * + * These arrive as strings, not numbers: they are written inside an i18next format expression + * (`{{ timestamp | timestampFormatter(relativeCompactMaxDays: 10) }}`), and i18next hands every + * argument over as text. The declared type says `number` because that is what a programmatic caller + * passes, so both have to be accepted. + */ +const asNumber = (value: unknown, fallback: number) => { + const parsed = + typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +/** + * Per-key calendar config may arrive as an object or as a JSON string. + * + * The string case is not a quirk to clean up: bundled defaults embed the config inside the i18next + * expression itself, so by the time it reaches a formatter it is text. + */ +const parseCalendarFormats = ( + value: TimestampFormatterOptions['calendarFormats'], + logger: (message?: string) => void, +): Record | undefined => { + if (!value) return undefined; + if (typeof value !== 'string') return value; + try { + return JSON.parse(value) as Record; + } catch (error) { + // Reported through the instance's logger, not through `translate` -- a diagnostic is not copy. + logger( + `Streami18n: calendarFormats is not valid JSON, ignoring it: ${value} (${ + error instanceof Error ? error.message : String(error) + })`, + ); + return undefined; + } +}; + +/** + * "Today" / "Yesterday" / "3d ago" / "2w ago", falling back to a short date. + * + * Every word goes through `t()`. The React Native SDK shipped this as a standalone formatter with the + * English baked in, which no dictionary could translate — and because the wording lived in a formatter + * body rather than a catalog value, the codegen's English-prose guard never saw it either. + */ +const relativeCompactDateString = ({ + maxDays, + maxWeeks, + tDateTimeParser, + timestamp, + translate, + weekRounding, +}: { + maxDays: number; + maxWeeks: number; + tDateTimeParser: TDateTimeParser; + timestamp: string | Date; + translate: LooseTranslateFunction; + weekRounding: 'ceil' | 'floor'; +}): string | null => { + const parsed = tDateTimeParser(timestamp as string); + if (!isDayOrMoment(parsed)) return null; + + const now = tDateTimeParser(new Date()); + if (!isDayOrMoment(now)) return null; + + const daysAgo = now.startOf('day').diff(parsed.startOf('day'), 'day'); + + // A future timestamp is not "Today" — fall straight through to a date. + if (daysAgo < 0) return parsed.format('DD/MM/YY'); + + if (daysAgo === 0) + return translate('relativeTime.today', RELATIVE_TIME_CATALOG['relativeTime.today']); + if (daysAgo === 1) + return translate( + 'relativeTime.yesterday', + RELATIVE_TIME_CATALOG['relativeTime.yesterday'], + ); + // Plural defaults rather than one `defaultValue`: English needs no distinction here, but a language + // whose plural categories differ has to be able to supply `_one` / `_other` and have i18next select. + if (daysAgo <= maxDays) { + return translate('relativeTime.daysAgo', { + count: daysAgo, + defaultValue_one: RELATIVE_TIME_CATALOG['relativeTime.daysAgo_one'], + defaultValue_other: RELATIVE_TIME_CATALOG['relativeTime.daysAgo_other'], + }); + } + + // `maxWeeks > 0` and a full week elapsed, both required: with `maxWeeks: 0` a 3-day-old timestamp + // has `Math.floor(3 / 7) === 0`, which would otherwise match and render "0w ago". + // + // The two roundings bound the window differently, which is the whole reason both exist: `floor` + // stops on the week *count*, `ceil` on the day count. See `relativeCompactWeekRounding`. + const weeksAgo = + weekRounding === 'ceil' ? Math.ceil(daysAgo / 7) : Math.floor(daysAgo / 7); + const withinWindow = + weekRounding === 'ceil' ? daysAgo <= maxWeeks * 7 : weeksAgo <= maxWeeks; + + if (maxWeeks > 0 && daysAgo >= 7 && withinWindow) { + return translate('relativeTime.weeksAgo', { + count: weeksAgo, + defaultValue_one: RELATIVE_TIME_CATALOG['relativeTime.weeksAgo_one'], + defaultValue_other: RELATIVE_TIME_CATALOG['relativeTime.weeksAgo_other'], + }); + } + + return parsed.format('DD/MM/YY'); +}; + +const timestampFormatter: FormatterFactory = + ({ logger, tDateTimeParser, translate }: FormatterContext) => + (value, _lng, options) => { + const { + calendar, + calendarFormats, + format, + relativeCompact, + relativeCompactMaxDays, + relativeCompactMaxWeeks, + relativeCompactWeekRounding, + } = options as TimestampFormatterOptions; + + // Nothing renderable: empty rather than the stringified value. `null` used to come out as the + // literal text "null" and an unparseable string as "Invalid Date", both of which are junk a user + // can see. `getDateString` has always guarded this; the formatter is a separate path and did not. + if (value === null || value === undefined) return ''; + if (typeof value === 'string' && isUnparseableDateString(value)) return ''; + + if (relativeCompact) { + const relative = relativeCompactDateString({ + maxDays: asNumber(relativeCompactMaxDays, DEFAULT_RELATIVE_COMPACT_MAX_DAYS), + maxWeeks: asNumber(relativeCompactMaxWeeks, DEFAULT_RELATIVE_COMPACT_MAX_WEEKS), + tDateTimeParser, + timestamp: value, + translate, + weekRounding: asWeekRounding(relativeCompactWeekRounding), + }); + if (relative !== null) return relative; + } + + const parsed = tDateTimeParser(value as string); + + if (isDayOrMoment(parsed)) { + if (calendar && typeof parsed.calendar === 'function') { + return parsed.calendar(undefined, parseCalendarFormats(calendarFormats, logger)); + } + return parsed.format(format); + } + if (isDate(parsed)) return parsed.toDateString(); + if (isNumberOrString(parsed)) return String(parsed); + return ''; + }; + +/** + * Renders a length of time, e.g. `600000` -> "10 minutes". + * + * Goes through the date library's `.duration()` rather than parsing the number as a timestamp — which + * would read 600000 as "10 minutes past the epoch" and render "57 years ago". + */ +const durationFormatter: FormatterFactory = + ({ dateTimeParser }: FormatterContext) => + (value, _lng, options) => { + const { format, withSuffix } = options as DurationFormatterOptions; + if (typeof dateTimeParser.duration !== 'function') return String(value); + + const duration = dateTimeParser.duration(value as number); + // Only dayjs's duration plugin has `.format`; moment durations humanize only. + if (format && typeof duration.format === 'function') return duration.format(format); + return duration.humanize(Boolean(withSuffix)); + }; + +const fromNowFormatter: FormatterFactory = + ({ tDateTimeParser }: FormatterContext) => + (value, _lng, options) => { + if (value === null || value === undefined) return ''; + const parsed = tDateTimeParser(value as string); + if (!isDayOrMoment(parsed) || typeof parsed.fromNow !== 'function') return ''; + return parsed.fromNow( + Boolean((options as { withoutSuffix?: boolean }).withoutSuffix), + ); + }; + +/** The formatters registered with i18next by default. */ +export const predefinedFormatters: PredefinedFormatters = { + durationFormatter, + fromNowFormatter, + timestampFormatter, +}; + +/* ------------------------------------------------------------------------------------------------ + * getDateString + * ---------------------------------------------------------------------------------------------- */ + +export type GetDateStringParams = TimestampFormatterOptions & { + /** The timestamp to render. */ + messageCreatedAt?: string | Date; + /** An integrator-supplied override, which wins over everything else. */ + formatDate?: (date: Date) => string; + /** The key carrying a formatter expression for this timestamp, if there is one. */ + timestampTranslationKey?: string; + t?: LooseTranslateFunction; + tDateTimeParser?: TDateTimeParser; +}; + +/** + * Resolves a timestamp to a display string. + * + * Resolution order, and why: an integrator's `formatDate` wins outright; then the translation key, so + * a language can restyle the timestamp without touching component props; then the parser. Returns + * `null` rather than a placeholder when there is nothing sensible to render, so callers can omit the + * element entirely. + */ +export const getDateString = ({ + calendar, + calendarFormats, + format, + formatDate, + messageCreatedAt, + relativeCompact, + relativeCompactMaxDays, + relativeCompactMaxWeeks, + relativeCompactWeekRounding, + t, + tDateTimeParser, + timestampTranslationKey, +}: GetDateStringParams): string | number | null => { + if ( + !messageCreatedAt || + (typeof messageCreatedAt === 'string' && isUnparseableDateString(messageCreatedAt)) + ) { + return null; + } + + if (formatDate) return formatDate(new Date(messageCreatedAt)); + + // Before the translation-key path, so a caller can ask for relative-compact rendering directly + // rather than only through a key whose expression sets it. Falls through when it declines (a future + // date, or no dayjs-like parser), so the normal formatting still applies. + if (relativeCompact && t && tDateTimeParser) { + const relative = relativeCompactDateString({ + maxDays: asNumber(relativeCompactMaxDays, DEFAULT_RELATIVE_COMPACT_MAX_DAYS), + maxWeeks: asNumber(relativeCompactMaxWeeks, DEFAULT_RELATIVE_COMPACT_MAX_WEEKS), + tDateTimeParser, + timestamp: messageCreatedAt, + translate: t, + weekRounding: asWeekRounding(relativeCompactWeekRounding), + }); + if (relative) return relative; + } + + if (t && timestampTranslationKey) { + // Only forward options that were actually supplied. + // + // These reach i18next as interpolation values and are merged over the arguments the key's own + // formatter expression declares — so passing `format: undefined` explicitly *overrides* + // `timestampFormatter(format: HH:mm)` with nothing, and the timestamp renders as a raw ISO string. + // The caller is usually a component forwarding optional props, so most of these are undefined most + // of the time. + const overrides: Record = {}; + const supplied = { + calendar, + calendarFormats, + format, + relativeCompact, + relativeCompactMaxDays, + relativeCompactMaxWeeks, + relativeCompactWeekRounding, + }; + for (const [key, value] of Object.entries(supplied)) { + if (value !== undefined) overrides[key] = value; + } + + const translated = t(asDynamicKey(timestampTranslationKey), { + ...overrides, + // A `Date`, not the raw value. Integrators override a `timestamp.*` key with their own + // formatter, and those read `options.timestamp` expecting a Date — passing the string through + // breaks them with `timestamp.toISOString is not a function`. + timestamp: new Date(messageCreatedAt), + }); + // i18next echoes the key back when nothing resolved it, which is how a miss is detected. + if (translated !== timestampTranslationKey) return translated; + } + + if (!tDateTimeParser) return null; + + const parsed = tDateTimeParser(messageCreatedAt); + + if (isDayOrMoment(parsed)) { + if (calendar && typeof parsed.calendar === 'function') { + return parsed.calendar( + undefined, + typeof calendarFormats === 'string' ? undefined : calendarFormats, + ); + } + return parsed.format(format); + } + if (isDate(parsed)) return parsed.toDateString(); + if (isNumberOrString(parsed)) return parsed; + return null; +}; + +/** + * The same resolution as {@link getDateString}, but always spelling the date out in full. + * + * A screen reader announcing "14:32" with no date is ambiguous, so the a11y string ignores the compact + * and calendar options a visual timestamp uses. + */ +export const getDateStringForA11y = ({ + messageCreatedAt, + t, + tDateTimeParser, + timestampTranslationKey, +}: Pick< + GetDateStringParams, + 'messageCreatedAt' | 't' | 'tDateTimeParser' | 'timestampTranslationKey' +>): string | number | null => + getDateString({ + calendar: false, + format: 'LLLL', + messageCreatedAt, + t, + tDateTimeParser, + timestampTranslationKey, + }); + +/** + * Calendar wording used by {@link getCalendarDateStringForA11y}, for the one bundled locale. + * + * Only English ships. A language an integrator registers supplies its own wording through + * `dayjsLocaleConfigForLanguage` (or `registerTranslation`'s third argument) — a per-locale block here + * is useless on its own without the matching `dayjs/locale/xx` beside it. + */ +export const A11Y_CALENDAR_FORMATS: Record = { + en: { + lastDay: '[Yesterday]', + lastWeek: 'dddd', + nextDay: '[Tomorrow]', + nextWeek: 'dddd [at] LT', + sameDay: '[Today]', + sameElse: 'L', + }, +}; + +export type GetCalendarDateStringForA11yParams = { + /** + * Calendar-format overrides applied over the locale defaults and the `sameElse: 'LL'` substitution. + * Use it where the visible date deliberately diverges — a channel preview shows `sameDay: 'LT'`, the + * time rather than "Today". + */ + calendarFormatOverrides?: Partial; + /** Calendar wording per language. Defaults to {@link A11Y_CALENDAR_FORMATS}. */ + calendarFormats?: Record; + messageCreatedAt?: string | Date; + tDateTimeParser?: TDateTimeParser; + /** + * The UI language, used to pick calendar wording. Plain `string`: it indexes `calendarFormats`, which + * an integrator extends for whatever language they registered — not `stream-chat`'s + * auto-translation `TranslationLanguage` union. + */ + userLanguage?: string; +}; + +/** + * A TTS-friendly calendar string, preserving relative wording. + * + * Distinct from {@link getDateStringForA11y}, which spells the date out in full via `LLLL`. Both exist + * because the two UI SDKs arrived at different answers and both are defensible: this one keeps + * "Today"/"Yesterday"/weekday names from the locale's calendar and substitutes `LL` ("April 8, 2026") + * only into the `sameElse` slot, because iOS VoiceOver reads a numeric date like "04/08/2026" + * character by character. Do not collapse them into one — that would silently change one SDK's + * announced labels. + * + * Returns `undefined` when there is nothing to announce, including when the parser has no calendar + * plugin, so the caller omits the label rather than announcing a malformed date. + */ +export const getCalendarDateStringForA11y = ({ + calendarFormatOverrides, + calendarFormats = A11Y_CALENDAR_FORMATS, + messageCreatedAt, + tDateTimeParser, + userLanguage, +}: GetCalendarDateStringForA11yParams): string | undefined => { + if ( + !messageCreatedAt || + (typeof messageCreatedAt === 'string' && isUnparseableDateString(messageCreatedAt)) || + !tDateTimeParser + ) { + return undefined; + } + + const parsed = tDateTimeParser(messageCreatedAt); + if (!isDayOrMoment(parsed) || !parsed.calendar) return undefined; + + const localeFormats = + (userLanguage && calendarFormats[userLanguage]) || calendarFormats.en; + + return parsed.calendar(undefined, { + ...localeFormats, + sameElse: 'LL', + ...calendarFormatOverrides, + }); +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000000..aee7c367fb --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,15 @@ +/** + * The shared i18n layer, published as `stream-chat/i18n`. + * + * Deliberately **not** re-exported from `stream-chat`'s root barrel: this module pulls in `i18next` and + * `dayjs`, and keeping them out of the root bundle is the entire reason it is a separate entry point. + * `scripts/bundle.mts` asserts that boundary at build time. + */ +export * from './dayjs'; +export * from './formatters'; +export * from './languageNames'; +export * from './Streami18n'; +export * from './TranslationBuilder'; +export * from './TranslationStore'; +export * from './translator'; +export * from './types'; diff --git a/src/i18n/languageNames.ts b/src/i18n/languageNames.ts new file mode 100644 index 0000000000..b379c1e668 --- /dev/null +++ b/src/i18n/languageNames.ts @@ -0,0 +1,103 @@ +import type { TranslationLanguage } from '../types'; + +/** + * The human-readable name of each language the API can auto-translate a message into. + * + * These are display copy for a **core-owned** set: `message.i18n.language` is typed + * {@link TranslationLanguage}, so core defines which languages exist and therefore owns their names + * too. A UI SDK uses them to say "Translated from German" rather than "Translated from de". + * + * `satisfies Record` is the drift gate, and it works in both + * directions: adding a language to the API union fails to compile until a name is supplied here, and a + * name for a language the union does not contain is rejected as an excess property. Before this lived + * in core, each UI SDK hand-maintained its own copy with nothing tying it to the union — so a miss + * could only be detected at runtime, by comparing the rendered string against the key. + * + * Names are in English on purpose. A language picker conventionally shows each language endonymously + * ("Deutsch", not "German"), but this is the *source* language of an auto-translated message rendered + * inside a sentence in the reader's own language, so it has to agree with the surrounding copy. An + * integrator wanting endonyms overrides the `language.*` keys. + */ +export const LANGUAGE_NAMES = { + af: 'Afrikaans', + am: 'Amharic', + ar: 'Arabic', + az: 'Azerbaijani', + bg: 'Bulgarian', + bn: 'Bengali', + bs: 'Bosnian', + cs: 'Czech', + da: 'Danish', + de: 'German', + el: 'Greek', + en: 'English', + es: 'Spanish', + 'es-MX': 'Spanish (Mexico)', + et: 'Estonian', + fa: 'Persian', + 'fa-AF': 'Dari', + fi: 'Finnish', + fr: 'French', + 'fr-CA': 'French (Canada)', + ha: 'Hausa', + he: 'Hebrew', + hi: 'Hindi', + hr: 'Croatian', + ht: 'Haitian Creole', + hu: 'Hungarian', + id: 'Indonesian', + it: 'Italian', + ja: 'Japanese', + ka: 'Georgian', + ko: 'Korean', + lt: 'Lithuanian', + lv: 'Latvian', + ms: 'Malay', + nl: 'Dutch', + no: 'Norwegian', + pl: 'Polish', + ps: 'Pashto', + pt: 'Portuguese', + ro: 'Romanian', + ru: 'Russian', + sk: 'Slovak', + sl: 'Slovenian', + so: 'Somali', + sq: 'Albanian', + sr: 'Serbian', + sv: 'Swedish', + sw: 'Swahili', + ta: 'Tamil', + th: 'Thai', + tl: 'Tagalog', + tr: 'Turkish', + uk: 'Ukrainian', + ur: 'Urdu', + vi: 'Vietnamese', + zh: 'Chinese (Simplified)', + 'zh-TW': 'Chinese (Traditional)', +} as const satisfies Record; + +/** + * The `language.*` slice of a translation catalog. + * + * A UI SDK intersects this into its own generated catalog, which makes `t('language.de')` a checked + * key rather than something that has to go through `asDynamicKey()`: + * + * ```ts + * type TranslationCatalog = GeneratedCatalog & LanguageNameCatalog; + * ``` + */ +export type LanguageNameCatalog = { + [K in keyof typeof LANGUAGE_NAMES as `language.${K & string}`]: (typeof LANGUAGE_NAMES)[K]; +}; + +/** + * {@link LANGUAGE_NAMES} keyed the way a catalog is, ready to merge into an SDK's bundled defaults. + * + * These keys are resolved from a runtime value (the message's source language), so there is no call + * site to carry an inline default — which is exactly why they have to ship as data. + */ +export const languageNameDefaults: Record = Object.fromEntries( + Object.entries(LANGUAGE_NAMES).map(([code, name]) => [`language.${code}`, name]), +); diff --git a/src/i18n/translator.ts b/src/i18n/translator.ts new file mode 100644 index 0000000000..1cbad8d9f8 --- /dev/null +++ b/src/i18n/translator.ts @@ -0,0 +1,76 @@ +import type { + AnyTranslationCatalog, + DynamicTranslationKey, + StreamTFunctionFor, +} from './types'; + +/** + * Brands a runtime-resolved string as a translation key. + * + * The brand on {@link DynamicTranslationKey} is required, so this is the only way to pass a key the + * compiler cannot see — which keeps every such escape deliberate and greppable. + */ +export const asDynamicKey = (key: string): DynamicTranslationKey => + key as DynamicTranslationKey; + +/** Matches `{{ name }}` / `{{name}}`, allowing dots so `{{ user.name }}` interpolates too. */ +const INTERPOLATION_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g; + +const interpolate = (copy: string, values: Record) => + copy.replace(INTERPOLATION_PATTERN, (whole, name: string) => + values[name] === undefined ? whole : String(values[name]), + ); + +/** + * The `t` in force before i18next has initialized, and the default for a UI SDK's translation context. + * + * It has to honour the inline `defaultValue`: every prose call site passes its English copy as the + * second argument, so echoing the key back would flash raw dotted paths on the first frame — and would + * render them permanently anywhere a context default is in play (a component used outside the SDK's + * provider). + * + * A ~30-line stand-in for i18next, deliberately: pulling i18next in just to render the first frame + * would defeat keeping it out of the default path. + */ +export const createDefaultTranslatorFunction = < + C extends AnyTranslationCatalog = AnyTranslationCatalog, + Bundled extends string = never, +>(): StreamTFunctionFor => + (( + key: string, + defaultValueOrOptions?: string | Record, + maybeOptions?: Record, + ) => { + // Prose: the copy arrives positionally. + if (typeof defaultValueOrOptions === 'string') { + return maybeOptions + ? interpolate(defaultValueOrOptions, maybeOptions) + : defaultValueOrOptions; + } + + const options = defaultValueOrOptions ?? maybeOptions; + if (!options) return key; + + // Plural call sites pass their copy as `defaultValue_one` / `defaultValue_other` inside the + // options object, so a bare `defaultValue` check would still leak the raw key for them. English + // only distinguishes one from other; a registered language's own categories are irrelevant here, + // since this function is only ever in play before i18next has initialized. + const resolved = + (options.count === 1 ? options.defaultValue_one : options.defaultValue_other) ?? + options.defaultValue; + + return typeof resolved === 'string' ? interpolate(resolved, options) : key; + }) as StreamTFunctionFor; + +/** + * Wraps an integrator's `parseMissingKeyHandler` so it only sees genuinely missing translations. + * + * i18next counts every prose key as missing — they render from the inline `defaultValue`, not from a + * resource bundle — and lets the handler's return value replace the rendered string. An unguarded + * handler therefore blanks out most of the UI. A resolved default arrives as the second argument, + * which is how the two cases are told apart. + */ +export const guardMissingKeyHandler = + (handler: (key: string, defaultValue?: string) => string) => + (key: string, defaultValue?: string) => + typeof defaultValue === 'string' ? defaultValue : handler(key, defaultValue); diff --git a/src/i18n/types.ts b/src/i18n/types.ts new file mode 100644 index 0000000000..180eaa9fb8 --- /dev/null +++ b/src/i18n/types.ts @@ -0,0 +1,375 @@ +import type { TOptions } from 'i18next'; + +/* ------------------------------------------------------------------------------------------------ + * Catalog-generic key machinery + * + * Core ships no translation catalog. Each UI SDK generates its own `keys.ts` from its `t()` call + * sites, then instantiates these helpers against it once. Everything here is type-only and erased at + * runtime. + * + * These are generic rather than driven by module augmentation on purpose: two catalogs must be able + * to coexist in one TypeScript program (a monorepo typechecking both UI SDKs in one pass), and a + * single augmented interface can only hold one. Augmentation is also ambient, which would leak an + * SDK's key union into an integrator's unrelated `t()` calls — the same objection that kept this out + * of i18next's `CustomTypeOptions`. + * ---------------------------------------------------------------------------------------------- */ + +/** The shape a generated `keys.ts` catalog satisfies: key -> its English copy. */ +export type AnyTranslationCatalog = Record; + +type Whitespace = ' ' | '\n' | '\t'; + +type Trim = S extends `${Whitespace}${infer R}` + ? Trim + : S extends `${infer R}${Whitespace}` + ? Trim + : S; + +/** `{{ value, formatter }}` and `{{ value | formatter(...) }}` — the name is the leading part. */ +type VarName = Trim< + S extends `${infer Name},${string}` + ? Name + : S extends `${infer Name}|${string}` + ? Name + : S +>; + +/** + * The interpolation variables a copy string requires. + * + * i18next ships `InterpolationMap`, but it does not trim the placeholder, so `{{ setting }}` yields a + * property literally named `" setting "`. SDK copy uses spaced placeholders throughout, so the + * placeholders are parsed here instead. + */ +type InterpolationVars = + S extends `${string}{{${infer V}}}${infer Rest}` + ? (VarName extends '' ? never : VarName) | InterpolationVars + : never; + +type InterpolationArgs = [InterpolationVars] extends [never] + ? Record + : { [K in InterpolationVars]: number | string }; + +/** Every plural category `Intl.PluralRules` can select. */ +export type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +export type CatalogKeyOf = keyof C & string; + +/** + * Keys whose catalog entries are plural forms (`_one` / `_other`). + * + * The `infer K` indirection is what makes the inner conditional distribute over the key union; a bare + * `Extract<..., \`${string}_other\`>` would not. + */ +export type PluralTranslationKeyOf = + CatalogKeyOf extends infer K + ? K extends `${infer Base}_other` + ? Base + : never + : never; + +/** + * Every key the SDK's `t` accepts: the singular entries plus the bare handle for each plural. + * + * This is the *call-site* key set. It is deliberately **not** the right type for a dictionary: a + * plural lives in the catalog as `_one` / `_other` while `t()` takes the bare ``, so + * keying a dictionary on this rejects the very entries a translator has to supply. Use + * {@link TranslationDictionaryOf} for that. + */ +export type TranslationKeyOf = + | Exclude, `${string}_${PluralSuffix}`> + | PluralTranslationKeyOf; + +/** + * A translation dictionary for `registerTranslation()` / `translationsForLanguage`. + * + * Restricted to the SDK's own keys, so a typo or a leftover key from a previous major is a compile + * error rather than an override that silently never applies. Keyed on the catalog rather than on + * {@link TranslationKeyOf} so the `_one` / `_other` plural entries are accepted. + * + * SDK copy only needs `_one` / `_other`, but a plural key accepts every category `Intl.PluralRules` + * can select, so Arabic, Hebrew or Russian can supply `_few`, `_many` and `_zero` and stay checked. A + * plural suffix on a key that is not plural is rejected. + */ +export type TranslationDictionaryOf = Partial< + Record, string> +> & + Partial}_${PluralSuffix}`, string>>; + +/** + * A dictionary that also admits keys the SDK does not define, so one instance can carry an + * application's own copy alongside the SDK's. + * + * Nothing catches a mistyped or stale SDK key here — it compiles, then never matches at runtime. + * {@link TranslationDictionaryOf} already covers the extra plural categories, so a language needing + * `_few` / `_many` / `_zero` does not have to give up key checking. + */ +export type LooseTranslationDictionaryOf = Partial< + Record, string> +> & + Record; + +/** The English copy for a key, used to infer that key's interpolation variables. */ +export type CopyFor = + K extends CatalogKeyOf + ? C[K] + : `${K}_other` extends CatalogKeyOf + ? C[`${K}_other` & CatalogKeyOf] + : string; + +/** + * Formatter expression keys, matched by prefix so overload resolution stays cheap. + * + * An SDK adds its own bundled prose keys through the `Bundled` parameter rather than widening this. + */ +export type FormatterExpressionKey = `timestamp.${string}` | `duration.${string}`; + +/** + * A translation key resolved from a runtime value rather than written literally. + * + * The brand is *required*, so a plain `string` is not assignable and the escape hatch has to be taken + * deliberately via `asDynamicKey()` — which also makes every such site greppable. + * + * @example t(asDynamicKey(command.description)) + */ +export type DynamicTranslationKey = string & { + readonly __dynamicTranslationKey: true; +}; + +/** Keys whose value is English copy, passed inline as the `defaultValue`. */ +export type ProseKeyOf< + C extends AnyTranslationCatalog, + Bundled extends string = never, +> = Exclude< + TranslationKeyOf, + FormatterExpressionKey | Bundled | PluralTranslationKeyOf +>; + +/** + * The SDK's translation function, instantiated once per catalog. + * + * Every prose call site passes its English copy inline as i18next's `defaultValue`, so the key stays + * stable across copy edits and a key missing from a custom dictionary still renders English. + * Interpolation variables are inferred from that copy, and plural keys require `count`. + * + * `Bundled` is the SDK's own set of keys resolved from bundled defaults rather than from an inline + * default — screen-reader labels and lookup-table entries that are ordinary prose but reach `t()` as + * runtime values, leaving nowhere to write a default. **It must default to `never`:** defaulting to + * `string` would collapse the prose overload and silently disable all key checking. + */ +export type StreamTFunctionFor< + C extends AnyTranslationCatalog, + Bundled extends string = never, +> = { + /** Plural key: `count` selects between the `_one` / `_other` copy. */ + >( + key: K, + options: TOptions & { count: number } & InterpolationArgs>, + ): string; + /** Bundled or formatter key: resolves from bundled defaults, so no inline default. */ + ( + key: FormatterExpressionKey | Bundled, + options?: TOptions & Record, + ): string; + /** + * Prose key with its English copy inline. + * + * Neither `defaultValue` nor `options` is tied to the key's exact copy. That would mean + * materialising the union of every copy string in the catalog, and the two checks it would buy are + * covered elsewhere: the default matching the generated catalog is enforced by the codegen drift + * gate, and a missing interpolation variable surfaces as a literal `{{ placeholder }}` in the + * rendered output, which the render tests assert on. + * + * Plural keys keep precise typing (see the first overload) because that union is small. + */ + >( + key: K, + defaultValue: string, + options?: TOptions & Record, + ): string; + /** Escape hatch for keys only known at runtime. */ + ( + key: DynamicTranslationKey, + defaultValueOrOptions?: string | (TOptions & Record), + options?: TOptions & Record, + ): string; +}; + +/* ------------------------------------------------------------------------------------------------ + * Date/time + * ---------------------------------------------------------------------------------------------- */ + +/** + * The dayjs/moment surface the formatters actually call. + * + * Structural on purpose: naming `moment-timezone` here would leak a type-only dependency into the + * published `.d.ts`, so consumers without it installed got unresolved types. Bring-your-own-Moment + * still works — it satisfies this shape. + * + * Declared with **method shorthand**, not property-with-function-type, and that is load-bearing: under + * `strictFunctionTypes` a function *property* is checked contravariantly, so `calendar?: (ref?: + * unknown) => string` demands an implementation accepting literally anything and Dayjs — whose + * `calendar` takes a narrower union — is not assignable. Method syntax is checked bivariantly, which is + * what duck-typing across two date libraries needs. + * + * `startOf` deliberately does **not** return `DateTimeLike`. Doing so made the whole type circular, and + * a real Moment then failed to satisfy it: checking `Moment` against `DateTimeLike` required + * `startOf`'s return `Moment` to satisfy `DateTimeLike`, which required `startOf` again. Method + * bivariance could not rescue it, and the failure was silent until a UI SDK's test passed `moment` in. + * Only `.diff()` is ever called on the result, so that is all the return type promises. + */ +export type DateTimeLike = { + format(template?: string): string; + calendar?(referenceTime?: DateTimeReference, formats?: Record): string; + fromNow?(withoutSuffix?: boolean): string; + diff(other: DateTimeOperand, unit?: DateTimeUnit): number; + startOf(unit: DateTimeUnit): { + diff(other: DateTimeOperand, unit?: DateTimeUnit): number; + }; + valueOf(): number; +}; + +/** + * A calendar-unit name (`'day'`, `'week'`, …) and a `diff` operand. + * + * Both are `any`, and deliberately so: dayjs and moment each declare their own narrow unions here + * (`OpUnitType` vs `unitOfTime.Diff`, `ConfigType` vs `MomentInput`), and a structural bridge that must + * accept either library cannot name one without excluding the other. Narrowing them is what made a real + * Moment fail to satisfy this type. Core only ever passes literals both libraries accept, and these + * arguments are inputs — nothing downstream depends on their type. + */ + +type DateTimeUnit = any; + +type DateTimeOperand = any; + +/** Anything a date library will accept as a point in time. */ +type DateTimeReference = DateTimeLike | Date | string | number | null | undefined; + +export type TDateTimeParserInput = string | number | Date; + +export type TDateTimeParserOutput = string | number | Date | DateTimeLike; + +export type TDateTimeParser = (input?: TDateTimeParserInput) => TDateTimeParserOutput; + +/** + * A duration, as returned by dayjs's or moment's `.duration()`. + * + * `format` is optional because only dayjs's duration plugin provides it; moment durations humanize + * only. + */ +export type DurationLike = { + humanize: (withSuffix?: boolean) => string; + format?: (template?: string) => string; +}; + +/** + * A date/time library *module*, as accepted by `Streami18nOptions.DateTimeParser`. + * + * Structural for the same reason as {@link DateTimeLike}: this admits `dayjs` and `moment` without + * naming either. It is the module rather than a parse function because `durationFormatter` needs + * `.duration()`, which lives on the module. + * + * Every member is **method shorthand**, and as with `DateTimeLike` that is load-bearing. As function + * properties they are checked contravariantly, so `locale?: (...args: unknown[]) => unknown` demanded + * an implementation accepting anything at all and rejected moment's overloaded, narrower `locale`. + * Method syntax is bivariant, which is what duck-typing across two libraries needs. + */ +export type DateTimeParserModule = ((input?: TDateTimeParserInput) => DateTimeLike) & { + duration?(input: number | string): DurationLike; + extend?(plugin: unknown, option?: unknown): unknown; + tz?: unknown; + locale?(...args: never[]): unknown; +}; + +/* ------------------------------------------------------------------------------------------------ + * Formatters + * ---------------------------------------------------------------------------------------------- */ + +/** + * A translate function loose enough for formatter internals and for accepting any SDK's narrowed `t`. + * + * Formatters resolve keys they are handed at runtime (and their own `relativeTime.*` copy), so they + * cannot be typed against a specific catalog. + * + * The parameters are `any` deliberately, and narrowing them breaks callers. An SDK's `t` is a + * four-overload callable whose key parameter is a union of its own catalog keys; under + * `strictFunctionTypes` a function *parameter* is checked contravariantly, so a `t` accepting only its + * own keys is **not** assignable to one declared as accepting any `string`. Typing these as `string` / + * `Record` therefore forces a cast at every call site that passes a real `t` in — + * which was nine of them in `stream-chat-react` alone. + */ +export type LooseTranslateFunction = ( + key: any, + defaultValueOrOptions?: any, + options?: any, +) => string; + +/** + * What a formatter is given about the instance it belongs to. + * + * Structural rather than the concrete class, so `types.ts` does not have to import `Streami18n.ts` + * and formatter factories stay testable in isolation. + */ +export type FormatterContext = { + currentLanguage: string; + /** The instance's logger, for diagnostics. A malformed formatter argument is not copy. */ + logger: (message?: string) => void; + /** The date library module. `durationFormatter` needs `.duration()`, which lives here. */ + dateTimeParser: DateTimeParserModule; + /** Parses a single timestamp, with the active locale and timezone already applied. */ + tDateTimeParser: TDateTimeParser; + translate: LooseTranslateFunction; + timezone?: string; +}; + +export type FormatterFactory = ( + context: FormatterContext, +) => (value: V, lng: string | undefined, options: Record) => string; + +export type TimestampFormatterOptions = { + /** Render relative to today ("Today at 14:32") via the dayjs calendar plugin. */ + calendar?: boolean | null; + /** Per-key calendar config. Replaces the locale's calendar wholesale for this key. */ + calendarFormats?: Record | string; + /** A dayjs/moment format template, e.g. `LT` or `dddd L`. */ + format?: string; + /** Render as "Today" / "Yesterday" / "3d ago" / "2w ago", then fall back to a date. */ + relativeCompact?: boolean; + relativeCompactMaxDays?: number; + relativeCompactMaxWeeks?: number; + /** + * How a "weeks ago" label rounds, and what bounds the window. + * + * `floor` (the default) reports whole elapsed weeks and stops once that count passes + * `relativeCompactMaxWeeks` — 8 days is "1w ago", 27 days is "3w ago". + * + * `ceil` rounds up and bounds on *days* instead, stopping after `relativeCompactMaxWeeks * 7` — 8 + * days is "2w ago", and 22 days falls through to a date. It exists because that is what + * `stream-chat-react` rendered before its formatter moved here, and changing those labels is a + * visible UI change rather than a refactor. New call sites should prefer `floor`. + */ + relativeCompactWeekRounding?: 'ceil' | 'floor'; +}; + +export type DurationFormatterOptions = { + format?: string; + withSuffix?: boolean; +}; + +export type PredefinedFormatters = { + durationFormatter: FormatterFactory; + fromNowFormatter: FormatterFactory; + /** + * Renders a timestamp. `relativeCompact: true` selects the "Today" / "3d ago" wording, which routes + * through `t()` and is therefore translatable. + * + * The React Native SDK's separate `relativeCompactDateFormatter` is gone rather than aliased here: it + * hardcoded English that no dictionary could reach, and an alias would have been a second name for + * one behaviour. A `timestamp.*` expression that used it becomes + * `{{ timestamp | timestampFormatter(relativeCompact: true) }}`. + */ + timestampFormatter: FormatterFactory; +}; + +export type CustomFormatters = Record>; diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index 42c07dc835..0469837a2f 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -20,6 +20,7 @@ import { AttachmentPreUploadMiddlewareExecutor, } from './middleware/attachmentManager'; import { StateStore } from '../store'; +import { CORE_NOTIFICATION_TYPE } from '../notifications'; import { generateUUIDv4 } from '../utils'; import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants'; import type { @@ -492,7 +493,7 @@ export class AttachmentManager { this.client.notifications.addError({ message: 'File is required for upload attachment', origin: { emitter: 'AttachmentManager', context: { attachment } }, - options: { type: 'validation:attachment:file:missing' }, + options: { type: CORE_NOTIFICATION_TYPE.attachmentFileMissing }, }); return; } @@ -501,7 +502,7 @@ export class AttachmentManager { this.client.notifications.addError({ message: 'Local upload attachment missing local id', origin: { emitter: 'AttachmentManager', context: { attachment } }, - options: { type: 'validation:attachment:id:missing' }, + options: { type: CORE_NOTIFICATION_TYPE.attachmentIdMissing }, }); return; } @@ -604,7 +605,7 @@ export class AttachmentManager { context: { attachment, blockedAttachment: localAttachment }, }, options: { - type: 'validation:attachment:upload:blocked', + type: CORE_NOTIFICATION_TYPE.attachmentUploadBlocked, metadata: { reason: localAttachment.localMetadata.uploadPermissionCheck?.reason, }, @@ -634,7 +635,7 @@ export class AttachmentManager { context: { attachment, failedAttachment }, }, options: { - type: 'api:attachment:upload:failed', + type: CORE_NOTIFICATION_TYPE.attachmentUploadFailed, metadata: { reason }, originalError: error instanceof Error ? error : undefined, }, diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index dc16bd16a1..e040ff2274 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -1,3 +1,4 @@ +import { CORE_NOTIFICATION_TYPE } from '../notifications'; import { AttachmentManager } from './attachmentManager'; import { CustomDataManager } from './CustomDataManager'; import { LinkPreviewsManager } from './linkPreviewsManager'; @@ -1008,7 +1009,7 @@ export class MessageComposer extends WithSubscriptions { context: { composer: this }, }, options: { - type: 'api:poll:create:failed', + type: CORE_NOTIFICATION_TYPE.pollCreateFailed, metadata: { reason: (error as Error).message, }, @@ -1034,7 +1035,7 @@ export class MessageComposer extends WithSubscriptions { context: { composer: this }, }, options: { - type: 'api:location:create:failed', + type: CORE_NOTIFICATION_TYPE.locationCreateFailed, metadata: { reason: (error as Error).message, }, diff --git a/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts b/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts index d33d7354a0..6cd1274a91 100644 --- a/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts +++ b/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../../middleware'; +import { CORE_NOTIFICATION_TYPE } from '../../../../notifications'; import type { MessageComposer } from '../../../messageComposer'; import type { AttachmentPostUploadMiddleware, @@ -27,7 +28,7 @@ export const createUploadErrorHandlerMiddleware = ( context: { attachment }, }, options: { - type: 'api:attachment:upload:failed', + type: CORE_NOTIFICATION_TYPE.attachmentUploadFailed, metadata: { reason }, originalError: error, }, diff --git a/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts b/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts index ff676a0c09..844fb6660a 100644 --- a/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts +++ b/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../../middleware'; +import { CORE_NOTIFICATION_TYPE } from '../../../../notifications'; import type { MessageComposer } from '../../../messageComposer'; import type { AttachmentPreUploadMiddleware, @@ -24,7 +25,7 @@ export const createBlockedAttachmentUploadNotificationMiddleware = ( context: { blockedAttachment: attachment }, }, options: { - type: 'validation:attachment:upload:blocked', + type: CORE_NOTIFICATION_TYPE.attachmentUploadBlocked, metadata: { reason: attachment.localMetadata.uploadPermissionCheck?.reason, }, diff --git a/src/messageComposer/middleware/messageComposer/attachments.ts b/src/messageComposer/middleware/messageComposer/attachments.ts index 3793b66625..46b5eabc1d 100644 --- a/src/messageComposer/middleware/messageComposer/attachments.ts +++ b/src/messageComposer/middleware/messageComposer/attachments.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; +import { CORE_NOTIFICATION_TYPE } from '../../../notifications'; import type { Attachment } from '../../../types'; import type { MessageComposer } from '../../messageComposer'; import type { LocalAttachment } from '../../types'; @@ -36,7 +37,7 @@ export const createAttachmentsCompositionMiddleware = ( context: { composer }, }, options: { - type: 'validation:attachment:upload:in-progress', + type: CORE_NOTIFICATION_TYPE.attachmentUploadInProgress, }, }); return discard(); diff --git a/src/messageComposer/middleware/pollComposer/index.ts b/src/messageComposer/middleware/pollComposer/index.ts index 6e49a2d115..c7707ec78c 100644 --- a/src/messageComposer/middleware/pollComposer/index.ts +++ b/src/messageComposer/middleware/pollComposer/index.ts @@ -1,3 +1,4 @@ export * from './PollComposerMiddlewareExecutor'; export * from './state'; export * from './types'; +export * from './validation'; diff --git a/src/messageComposer/middleware/pollComposer/state.ts b/src/messageComposer/middleware/pollComposer/state.ts index 2cce5ce8c2..3061a50e8d 100644 --- a/src/messageComposer/middleware/pollComposer/state.ts +++ b/src/messageComposer/middleware/pollComposer/state.ts @@ -6,6 +6,12 @@ import type { PollComposerStateChangeMiddlewareValue, TargetedPollOptionTextUpdate, } from './types'; +import type { PollComposerValidationError } from './validation'; +import { + isPollComposerValidationError, + POLL_COMPOSER_VALIDATION_CODE, + pollComposerValidationError, +} from './validation'; export const VALID_MAX_VOTES_VALUE_REGEX = /^([2-9]|10)$/; @@ -14,8 +20,11 @@ export const MAX_POLL_OPTIONS = 100 as const; const textFieldIsEmpty = (text: string) => !text.trim(); export type PollStateValidationOutput = Partial< - Omit, 'options'> & { - options?: Record; + Omit< + Record, + 'options' + > & { + options?: Record; } >; @@ -31,22 +40,36 @@ export const pollStateChangeValidators: Partial< enforce_unique_vote: () => ({ max_votes_allowed: undefined }), max_votes_allowed: ({ data, value }) => { if (data.enforce_unique_vote && value) - return { max_votes_allowed: 'Enforce unique vote is enabled' }; + return { + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesUniqueVoteEnforced, + ), + }; const numericMatch = value.match(/^[0-9]+$/); if (!numericMatch && value) { - return { max_votes_allowed: 'Only numbers are allowed' }; + return { + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric, + ), + }; } if (value?.length > 1 && !value.match(VALID_MAX_VOTES_VALUE_REGEX)) - return { max_votes_allowed: 'Type a number from 2 to 10' }; + return { + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange, + ), + }; return { max_votes_allowed: undefined }; }, options: ({ value: options }) => { - const errors: Record = {}; + const errors: Record = {}; const seenOptions = new Set(); options.forEach((option: { id: string; text: string }) => { if (seenOptions.has(option.text) && option.text.length) { - errors[option.id] = 'Option already exists'; + errors[option.id] = pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionDuplicate, + ); } else { seenOptions.add(option.text); } @@ -62,7 +85,7 @@ export const defaultPollFieldChangeEventValidators: Partial< name: ({ currentError, value }) => value && currentError ? { name: undefined } - : { name: typeof currentError === 'string' ? currentError : undefined }, + : { name: isPollComposerValidationError(currentError) ? currentError : undefined }, }; export const defaultPollFieldBlurEventValidators: Partial< @@ -70,11 +93,18 @@ export const defaultPollFieldBlurEventValidators: Partial< > = { max_votes_allowed: ({ value }) => { if (value && !value.match(VALID_MAX_VOTES_VALUE_REGEX)) - return { max_votes_allowed: 'Type a number from 2 to 10' }; + return { + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange, + ), + }; return { max_votes_allowed: undefined }; }, name: ({ value }) => { - if (textFieldIsEmpty(value)) return { name: 'Question is required' }; + if (textFieldIsEmpty(value)) + return { + name: pollComposerValidationError(POLL_COMPOSER_VALIDATION_CODE.nameRequired), + }; return { name: undefined }; }, options: (params) => { @@ -83,7 +113,9 @@ export const defaultPollFieldBlurEventValidators: Partial< params.value.forEach((option: { id: string; text: string }, index: number) => { const isTheLastOption = index === params.value.length - 1; if (textFieldIsEmpty(option.text) && !isTheLastOption) { - errors[option.id] = 'Option is empty'; + errors[option.id] = pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, + ); } }); return Object.keys(errors).length > 0 ? { options: errors } : { options: undefined }; diff --git a/src/messageComposer/middleware/pollComposer/types.ts b/src/messageComposer/middleware/pollComposer/types.ts index 9dcca6b030..6ff2aac7d1 100644 --- a/src/messageComposer/middleware/pollComposer/types.ts +++ b/src/messageComposer/middleware/pollComposer/types.ts @@ -1,5 +1,6 @@ import type { MiddlewareExecutionResult } from '../../../middleware'; import type { CreatePollRequest, VotingVisibility } from '../../../types'; +import type { PollComposerValidationError } from './validation'; export type PollComposerOption = { id: string; @@ -19,9 +20,15 @@ export type UpdateFieldsData = Partial, 'options'> & { - options?: Record; + Omit, 'options'> & { + options?: Record; } >; diff --git a/src/messageComposer/middleware/pollComposer/validation.ts b/src/messageComposer/middleware/pollComposer/validation.ts new file mode 100644 index 0000000000..506001c229 --- /dev/null +++ b/src/messageComposer/middleware/pollComposer/validation.ts @@ -0,0 +1,76 @@ +/** + * Stable identifiers for poll-composer field validation failures. + * + * **Not notifications, despite the shared `domain:entity:operation:result` shape.** These are *field* + * errors, rendered inline beside the input that produced them, and they never reach + * `NotificationManager` — routing them there would raise a toast per keystroke. `CORE_NOTIFICATION_TYPE` + * is the notification counterpart; the two sets are disjoint and neither substitutes for the other. + * + * **These values are public API.** UI SDKs key their translation tables on them, so renaming one is a + * breaking change. + */ +export const POLL_COMPOSER_VALIDATION_CODE = { + maxVotesNotNumeric: 'validation:poll:maxVotes:notNumeric', + maxVotesOutOfRange: 'validation:poll:maxVotes:outOfRange', + maxVotesUniqueVoteEnforced: 'validation:poll:maxVotes:uniqueVoteEnforced', + nameRequired: 'validation:poll:name:required', + optionDuplicate: 'validation:poll:option:duplicate', + optionEmpty: 'validation:poll:option:empty', +} as const; + +export type PollComposerValidationCode = + (typeof POLL_COMPOSER_VALIDATION_CODE)[keyof typeof POLL_COMPOSER_VALIDATION_CODE]; + +/** + * Untranslated English for each code. + * + * Kept here rather than at the call sites so one code cannot end up with two different wordings, and + * so the whole set is reviewable in one place. This is a developer-facing fallback — the wording is + * not part of the public contract and may change in a minor release. + */ +const POLL_COMPOSER_VALIDATION_MESSAGE: Record = { + [POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric]: 'Only numbers are allowed', + [POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange]: 'Type a number from 2 to 10', + [POLL_COMPOSER_VALIDATION_CODE.maxVotesUniqueVoteEnforced]: + 'Enforce unique vote is enabled', + [POLL_COMPOSER_VALIDATION_CODE.nameRequired]: 'Question is required', + [POLL_COMPOSER_VALIDATION_CODE.optionDuplicate]: 'Option already exists', + [POLL_COMPOSER_VALIDATION_CODE.optionEmpty]: 'Option is empty', +}; + +/** + * A poll-composer field validation failure. + * + * `code` is the stable identifier to resolve localized copy from. `message` carries untranslated + * English alongside it so a consumer with no i18n layer still renders something, and so an + * identifier a consumer does not recognize degrades to readable text instead of a blank field. + */ +export type PollComposerValidationError = { + /** Stable identifier. See {@link POLL_COMPOSER_VALIDATION_CODE}. */ + code: PollComposerValidationCode; + /** Untranslated English fallback. Not part of the public contract. */ + message: string; + /** Extra context for interpolation, e.g. the offending value. */ + metadata?: Record; +}; + +/** Builds a {@link PollComposerValidationError}, filling in the English fallback for `code`. */ +export const pollComposerValidationError = ( + code: PollComposerValidationCode, + metadata?: Record, +): PollComposerValidationError => ({ + code, + message: POLL_COMPOSER_VALIDATION_MESSAGE[code], + ...(metadata ? { metadata } : {}), +}); + +/** + * Narrows a field's error to a single failure. + * + * `options` errors are keyed by option id, so a field error is either one `PollComposerValidationError` or a + * record of them; this distinguishes the two. + */ +export const isPollComposerValidationError = ( + value: unknown, +): value is PollComposerValidationError => + typeof value === 'object' && value !== null && 'code' in value && 'message' in value; diff --git a/src/messageComposer/middleware/textComposer/commandUtils.ts b/src/messageComposer/middleware/textComposer/commandUtils.ts index a46e7cf2b7..fe40bdfa42 100644 --- a/src/messageComposer/middleware/textComposer/commandUtils.ts +++ b/src/messageComposer/middleware/textComposer/commandUtils.ts @@ -1,4 +1,5 @@ import type { MessageComposer } from '../../messageComposer'; +import { CORE_NOTIFICATION_TYPE } from '../../../notifications'; import type { Command, UserResponse } from '../../../types'; import type { CommandSendability } from '../../configuration'; import type { CommandSearchSource } from './commands'; @@ -72,7 +73,7 @@ export const notifyCommandDisabled = (composer: MessageComposer, command: Comman context: { command, composer }, }, options: { - type: 'validation:command:disabled', + type: CORE_NOTIFICATION_TYPE.commandDisabled, metadata: { command: command.name, reason: disabledReason, @@ -99,7 +100,7 @@ export const notifyCommandNotReady = ({ context: { command: sendability.command, composer }, }, options: { - type: 'validation:command:not-ready', + type: CORE_NOTIFICATION_TYPE.commandNotReady, metadata: { command: sendability.command.name, ...(sendability.reason ? { reason: sendability.reason } : {}), diff --git a/src/notifications/types.ts b/src/notifications/types.ts index f764f69ead..637898c0c2 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -18,11 +18,58 @@ export type NotificationAction = { export type NotificationOrigin = { emitter: string; context?: Record }; +/** + * Every notification type emitted by `stream-chat` itself. + * + * Format is `domain:entity:operation:result`: + * - `domain` — where it happened: `api`, `validation`, `permission`, `network`, `auth`, `system` + * - `entity` — what was operated on: `attachment`, `poll`, `message`, `command`, `location` + * - `operation` — what was attempted, lowerCamelCase: `upload`, `create`, `castVote`, `jumpToLatest` + * - `result` — what happened: `failed`, `blocked`, `invalid`, `missing`, `limit`, `success`, + * or a short hyphenated state such as `in-progress` / `not-ready` + * + * **These values are public API.** UI SDKs key their translation tables on them, so renaming one is + * a breaking change. Emit them through this map rather than writing the literal inline, so the whole + * set stays greppable from one place and a typo is a compile error. + * + * Consumers translating notifications should switch on {@link Notification.type} rather than matching + * on {@link Notification.message}, which is untranslated English intended as a developer-facing + * fallback. + */ +export const CORE_NOTIFICATION_TYPE = { + attachmentFileMissing: 'validation:attachment:file:missing', + attachmentIdMissing: 'validation:attachment:id:missing', + attachmentUploadBlocked: 'validation:attachment:upload:blocked', + attachmentUploadFailed: 'api:attachment:upload:failed', + attachmentUploadInProgress: 'validation:attachment:upload:in-progress', + /** Carries `metadata.reason` (`'editing' | 'quoted_message'`), which the message depends on. */ + commandDisabled: 'validation:command:disabled', + commandNotReady: 'validation:command:not-ready', + locationCreateFailed: 'api:location:create:failed', + /** Jumping to a specific message failed. */ + messageJumpFailed: 'api:message:jump:failed', + /** Jumping to the latest message failed. */ + messageJumpToLatestFailed: 'api:message:jumpToLatest:failed', + pollCastVoteLimit: 'validation:poll:castVote:limit', + pollCreateFailed: 'api:poll:create:failed', +} as const; + +/** A notification type emitted by `stream-chat` itself. See {@link CORE_NOTIFICATION_TYPE}. */ +export type CoreNotificationType = + (typeof CORE_NOTIFICATION_TYPE)[keyof typeof CORE_NOTIFICATION_TYPE]; + /** Represents a single notification message */ export type Notification = { /** Unique identifier for the notification */ id: string; - /** The notification message text */ + /** + * Untranslated English text describing what happened. + * + * This is a **developer-facing fallback, not display copy.** It is not localized and its exact + * wording is not part of the public contract — it can be reworded in a minor release. Anything + * user-facing should resolve {@link Notification.type} to its own copy and fall back to this string + * only for an identifier it does not recognize. + */ message: string; /** Timestamp when notification was created */ createdAt: number; @@ -36,49 +83,14 @@ export type Notification = { /** The severity level of the notification (defaults to `undefined` unless explicitly provided). */ severity?: NotificationSeverity; /** - * Optional code that can be used to group the notifications of the same type, e.g. attachment-upload-blocked. - * Format: domain:entity:operation:result - * domain: where the error occurred (api, validation, permission, etc) - * entity: what was being operated on (poll, attachment, message, etc) - * operation: what was being attempted (create, upload, validate, etc) - * result: what happened (failed, blocked, invalid, etc) - * - * Poll related errors - * 'api:poll:create:failed' // API call to create poll failed - * 'validation:poll:create:invalid' // Poll creation validation failed - * - * Attachment related errors - * 'validation:attachment:file:missing' // Required file is missing - * 'permission:attachment:upload:blocked' // Upload blocked due to permissions - * 'api:attachment:upload:failed' // API upload call failed - * 'validation:attachment:type:unsupported' // Unsupported file type - * 'validation:attachment:size:exceeded' // File size too large - * 'validation:attachment:count:exceeded' // Too many attachments - * - * MessageRequest related errors - * 'api:message:send:failed' // MessageRequest send failed - * 'validation:message:content:empty' // MessageRequest content validation failed - * - * Channel related errors - * 'api:channel:join:failed' // Channel join failed - * 'permission:channel:access:denied' // Channel access denied - * - * Authentication related errors - * 'auth:token:expired' // Auth token expired - * 'auth:token:invalid' // Invalid auth token - * - * Network related errors - * 'network:request:timeout' // Request timed out - * 'network:request:failed' // Network request failed - * - * Rate limiting - * 'rate:limit:exceeded' // Rate limit exceeded + * Stable identifier for what this notification is about, used to group notifications of the same + * kind and — for UI SDKs — to resolve a translation without matching on the English `message`. * - * System errors - * 'system:internal:error' // Internal system error - * 'system:resource:unavailable'; // System resource unavailable + * Values emitted by `stream-chat` are enumerated in {@link CORE_NOTIFICATION_TYPE}; those are the + * ones that autocomplete. The type stays open so SDKs and integrators can emit their own + * identifiers following the same `domain:entity:operation:result` convention. */ - type?: string; + type?: CoreNotificationType | (string & {}); /** Optional auto-dismiss duration in milliseconds. The timeout starts when NotificationManager.startTimeout() is called. */ duration?: number; /** Optional metadata to attach to the notification */ diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 1121d868d0..8863f8a96d 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -27,6 +27,7 @@ import type { UserResponse, } from '../../types'; import type { Channel } from '../../channel'; +import { CORE_NOTIFICATION_TYPE } from '../../notifications'; import { StateStore } from '../../store'; import { computeOwnReactions, @@ -546,7 +547,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.channel.getClient().notifications.addError({ message: 'Jump to message unsuccessful', origin: { emitter: 'MessagePaginator.jumpToMessage', context: { messageId } }, - options: { type: 'api:messages:query:failed' }, + options: { type: CORE_NOTIFICATION_TYPE.messageJumpFailed }, }); return false; } @@ -598,7 +599,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.channel.getClient().notifications.addError({ message: 'Jump to latest message unsuccessful', origin: { emitter: 'MessagePaginator.jumpToTheLatestMessage' }, - options: { type: 'api:message:query:failed' }, + options: { type: CORE_NOTIFICATION_TYPE.messageJumpToLatestFailed }, }); return false; } diff --git a/src/poll.ts b/src/poll.ts index 34cf113b03..f3a868e074 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -1,4 +1,5 @@ import { StateStore } from './store'; +import { CORE_NOTIFICATION_TYPE } from './notifications'; import type { StreamChat } from './client'; import type { EventPayload, @@ -307,7 +308,7 @@ export class Poll { context: { messageId, optionId }, }, options: { - type: 'validation:poll:castVote:limit', + type: CORE_NOTIFICATION_TYPE.pollCastVoteLimit, }, }); return; diff --git a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts index 781071fd7a..e5a2da8233 100644 --- a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts +++ b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts @@ -9,6 +9,10 @@ import { createPollComposerStateMiddleware, PollComposerStateMiddlewareFactoryOptions, } from '../../../../../src/messageComposer/middleware/pollComposer/state'; +import { + POLL_COMPOSER_VALIDATION_CODE, + pollComposerValidationError, +} from '../../../../../src/messageComposer/middleware/pollComposer/validation'; import { VotingVisibility } from '../../../../../src/types'; const setupHandlerParams = (initialState: PollComposerStateChangeMiddlewareValue) => { @@ -213,8 +217,8 @@ describe('PollComposerStateMiddleware', () => { }), ); - expect(result.state.nextState.errors.max_votes_allowed).toBe( - 'Enforce unique vote is enabled', + expect(result.state.nextState.errors.max_votes_allowed?.code).toBe( + POLL_COMPOSER_VALIDATION_CODE.maxVotesUniqueVoteEnforced, ); expect(result.state.nextState.data.max_votes_allowed).toBe('5'); expect(result.status).toBeUndefined; @@ -518,8 +522,8 @@ describe('PollComposerStateMiddleware', () => { expect(result.state.nextState.errors.options).toBeDefined(); expect(Object.keys(result.state.nextState.errors.options!)).toHaveLength(1); - expect(result.state.nextState.errors.options!['option-id1']).toBe( - 'Option is empty', + expect(result.state.nextState.errors.options!['option-id1'].code).toBe( + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, ); }); it('should not validate options with only white spaces on blur', async () => { @@ -539,11 +543,11 @@ describe('PollComposerStateMiddleware', () => { expect(result.state.nextState.errors.options).toBeDefined(); expect(Object.keys(result.state.nextState.errors.options!)).toHaveLength(2); - expect(result.state.nextState.errors.options!['option-id1']).toBe( - 'Option is empty', + expect(result.state.nextState.errors.options!['option-id1'].code).toBe( + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, ); - expect(result.state.nextState.errors.options!['option-id2']).toBe( - 'Option already exists', + expect(result.state.nextState.errors.options!['option-id2'].code).toBe( + POLL_COMPOSER_VALIDATION_CODE.optionDuplicate, ); }); @@ -581,7 +585,9 @@ describe('PollComposerStateMiddleware', () => { ); expect(result.state.nextState.errors.options).toEqual({ - 'option-2': 'Option already exists', + 'option-2': pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionDuplicate, + ), }); }); diff --git a/test/unit/codegen/i18n/generate.test.ts b/test/unit/codegen/i18n/generate.test.ts new file mode 100644 index 0000000000..d4f65ab557 --- /dev/null +++ b/test/unit/codegen/i18n/generate.test.ts @@ -0,0 +1,464 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import ts from 'typescript'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildCatalog, generateI18nKeys, readStringMap } from '../../../../codegen/i18n'; +import type { GeneratorConfig } from '../../../../codegen/i18n'; + +/** + * Fixtures are written to a scratch directory and the generator runs in-process against them. + * + * In-process rather than by spawning the script: the failures come back as data, so a test asserts on + * the failure itself instead of scraping stderr — which is what most of the length of the SDK-side + * version of this suite was. It also means real stack traces on failure. + */ +const scratchDirs: string[] = []; + +const makeProject = (files: Record) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-codegen-')); + scratchDirs.push(dir); + for (const [relative, contents] of Object.entries(files)) { + const full = path.join(dir, relative); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return dir; +}; + +const configFor = ( + dir: string, + overrides: Partial = {}, +): GeneratorConfig => ({ + keysOut: path.join(dir, 'src/i18n/keys.ts'), + log: () => {}, + runtimeDefaultsPath: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + srcRoot: path.join(dir, 'src'), + ts, + ...overrides, +}); + +const runtimeDefaults = (entries: Record) => + `export const runtimeDefaults = {\n${Object.entries(entries) + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`) + .join('\n')}\n};\n`; + +afterEach(() => { + while (scratchDirs.length) { + fs.rmSync(scratchDirs.pop() as string, { force: true, recursive: true }); + } +}); + +describe('call-site reading', () => { + it('collects prose, plural and bundled keys', () => { + const dir = makeProject({ + 'src/Component.tsx': ` + const C = () => { + t('common.cancel.label', 'Cancel'); + t('channel.memberCount.title', { + count, + defaultValue_one: '{{ count }} member', + defaultValue_other: '{{ count }} members', + }); + t('timestamp.MessageTimestamp', { timestamp }); + return null; + }; + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter }}', + }), + }); + + const { catalog, failures } = buildCatalog(configFor(dir)); + + expect(failures).toEqual([]); + expect(Object.fromEntries(catalog)).toEqual({ + 'channel.memberCount.title_one': '{{ count }} member', + 'channel.memberCount.title_other': '{{ count }} members', + 'common.cancel.label': 'Cancel', + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter }}', + }); + }); + + it('recognises a `.t(...)` property call as well as a bare `t(...)`', () => { + const dir = makeProject({ + 'src/Component.tsx': `i18n.t('common.ok.label', 'OK');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { catalog } = buildCatalog(configFor(dir)); + expect(catalog.get('common.ok.label')).toBe('OK'); + }); + + it('skips ignored directories', () => { + const dir = makeProject({ + 'src/__tests__/Component.test.tsx': `t('only.in.tests', 'Nope');`, + 'src/Component.tsx': `t('real.key', 'Yes');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { catalog } = buildCatalog(configFor(dir)); + expect(catalog.has('only.in.tests')).toBe(false); + expect(catalog.has('real.key')).toBe(true); + }); +}); + +describe('guards', () => { + it('fails on a key used with two different inline copies', () => { + const dir = makeProject({ + 'src/A.tsx': `t('common.cancel.label', 'Cancel');`, + 'src/B.tsx': `t('common.cancel.label', 'Dismiss');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['conflicting-copy']); + expect(failures[0].entries.join('\n')).toContain('common.cancel.label'); + }); + + /** Without this the key renders as a raw dotted path in the UI. */ + it('fails on a key with no inline default and no bundled entry', () => { + const dir = makeProject({ + 'src/A.tsx': `t('forgot.the.copy');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['unresolvable-key']); + expect(failures[0].entries.join('\n')).toContain('forgot.the.copy'); + }); + + /** + * A bundled plural, both ways round. + * + * i18next resolves `t('x.y', { count })` as `x.y_`, never as `x.y`. The guards used to + * check the bare key for every no-inline-copy call site, which got both cases exactly backwards: + * the correct catalog was rejected and the broken one waved through. + */ + it('accepts a bundled plural stored under its category suffixes', () => { + const dir = makeProject({ + 'src/Component.tsx': `const C = () => t('channel.unread.label', { count });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'channel.unread.label_one': '{{count}} unread', + 'channel.unread.label_other': '{{count}} unread', + }), + }); + + const { catalog, failures } = buildCatalog(configFor(dir)); + + expect(failures).toEqual([]); + expect([...catalog.keys()]).toEqual([ + 'channel.unread.label_one', + 'channel.unread.label_other', + ]); + }); + + it('fails on a bundled plural stored under the bare key', () => { + const dir = makeProject({ + 'src/Component.tsx': `const C = () => t('channel.unread.label', { count });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'channel.unread.label': '{{count}} unread', + }), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures).toHaveLength(1); + expect(failures[0].kind).toBe('bundled-plural-shape'); + expect(failures[0].entries[0]).toContain('channel.unread.label'); + expect(failures[0].summary).toContain('_one'); + }); + + it('names the suffixed key when a bundled plural is missing entirely', () => { + const dir = makeProject({ + 'src/Component.tsx': `const C = () => t('channel.unread.label', { count });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures).toHaveLength(1); + expect(failures[0].kind).toBe('unresolvable-key'); + // The suffixed form, since that is what has to be added -- not the bare key the call site uses. + expect(failures[0].entries[0]).toContain('channel.unread.label_other'); + }); + + it('reads `count` whether it is shorthand or written out', () => { + const dir = makeProject({ + 'src/Component.tsx': ` + const C = () => { + t('a.one.label', { count }); + t('b.two.label', { count: total }); + }; + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + // Both treated as plurals, so both are reported under their `_other` form. + expect(failures[0].entries).toEqual([ + expect.stringContaining('a.one.label_other'), + expect.stringContaining('b.two.label_other'), + ]); + }); + + /** The bundled value wins, so the call site's copy would silently never render. */ + it('fails on a key present both inline and in the bundled defaults', () => { + const dir = makeProject({ + 'src/A.tsx': `t('common.cancel.label', 'Cancel');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'common.cancel.label': 'Abort', + }), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['shadowed-key']); + expect(failures[0].entries.join('\n')).toContain('Abort'); + }); + + it('fails when one key is a strict dotted prefix of another', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('poll.title', 'Title'); + t('poll.title.text', 'Text'); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['prefix-collision']); + expect(failures[0].entries.join('\n')).toContain('is a strict prefix of'); + }); + + /** Compared on segment boundaries, so a shared word prefix is fine. */ + it('allows a shared prefix that is not a segment boundary', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('poll.title', 'Title'); + t('poll.titleText', 'Title text'); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + expect(buildCatalog(configFor(dir)).failures).toEqual([]); + }); + + it('throws with every failure formatted, and writes nothing', () => { + const dir = makeProject({ + 'src/A.tsx': `t('forgot.the.copy');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + const config = configFor(dir); + + expect(() => generateI18nKeys(config)).toThrow(/no inline default/); + expect(fs.existsSync(config.keysOut)).toBe(false); + }); +}); + +describe('output', () => { + it('writes a sorted, type-only catalog', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('z.last.label', 'Last'); + t('a.first.label', 'First'); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + const config = configFor(dir); + + generateI18nKeys(config); + const written = fs.readFileSync(config.keysOut, 'utf8'); + + expect(written).toContain('export type TranslationCatalog = {'); + expect(written.indexOf('a.first.label')).toBeLessThan( + written.indexOf('z.last.label'), + ); + // Type-only: nothing that emits a runtime value. + expect(written).not.toMatch(/^(export )?const /m); + }); + + it('emits the bundled key union only when asked', () => { + const dir = makeProject({ + 'src/A.tsx': `t('a11y.close.label');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ 'a11y.close.label': 'Close' }), + }); + + const withUnion = configFor(dir, { emitBundledKeyUnion: true }); + generateI18nKeys(withUnion); + expect(fs.readFileSync(withUnion.keysOut, 'utf8')).toContain( + 'export type BundledTranslationKey =', + ); + + const withoutUnion = configFor(dir, { + keysOut: path.join(dir, 'src/i18n/keys-no-union.ts'), + }); + generateI18nKeys(withoutUnion); + expect(fs.readFileSync(withoutUnion.keysOut, 'utf8')).not.toContain( + 'BundledTranslationKey', + ); + }); + + /** `keys.ts` is type-only, so a test cannot iterate it — this is its data twin. */ + it('leaves plural categories out of the bundled key union', () => { + const dir = makeProject({ + 'src/Component.tsx': ` + const C = () => { + t('channel.unread.label', { count }); + t('timestamp.Message', {}); + }; + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'channel.unread.label_one': '{{count}} unread', + 'channel.unread.label_other': '{{count}} unread', + 'timestamp.Message': '{{ timestamp | timestampFormatter }}', + }), + }); + const config = configFor(dir, { emitBundledKeyUnion: true }); + + generateI18nKeys(config); + const written = fs.readFileSync(config.keysOut, 'utf8'); + + // The plural overload already accepts the bare key; offering `t('…_other')` would resolve nothing. + expect(written).toContain( + 'export type BundledTranslationKey =\n | "timestamp.Message"', + ); + expect(written).not.toContain('| "channel.unread.label_one"'); + expect(written).not.toContain('| "channel.unread.label_other"'); + // Still present in the catalog itself -- a dictionary has to be able to supply them. + expect(written).toContain('"channel.unread.label_other":'); + }); + + it('writes a JSON fixture twin when configured', () => { + const dir = makeProject({ + 'src/A.tsx': `t('common.cancel.label', 'Cancel');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + const fixtureOut = path.join(dir, 'src/i18n/__tests__/catalog.fixture.json'); + + generateI18nKeys(configFor(dir, { fixtureOut })); + + expect(JSON.parse(fs.readFileSync(fixtureOut, 'utf8'))).toEqual({ + 'common.cancel.label': 'Cancel', + }); + }); + + it('excludes formatter expressions from the translator JSON export by default', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('common.cancel.label', 'Cancel'); + t('timestamp.MessageTimestamp', { timestamp }); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter }}', + }), + }); + const jsonOut = path.join(dir, 'en.json'); + + generateI18nKeys(configFor(dir, { json: { out: jsonOut } })); + expect(Object.keys(JSON.parse(fs.readFileSync(jsonOut, 'utf8')))).toEqual([ + 'common.cancel.label', + ]); + + generateI18nKeys(configFor(dir, { json: { includeFormats: true, out: jsonOut } })); + expect(Object.keys(JSON.parse(fs.readFileSync(jsonOut, 'utf8'))).sort()).toEqual([ + 'common.cancel.label', + 'timestamp.MessageTimestamp', + ]); + }); + + it('names formatter keys that still hide English copy', () => { + const dir = makeProject({ + 'src/A.tsx': `t('timestamp.UserActivity', { timestamp });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'timestamp.UserActivity': 'Last seen {{ timestamp | fromNowFormatter }}', + }), + }); + const logged: string[] = []; + + generateI18nKeys( + configFor(dir, { + json: { out: path.join(dir, 'en.json') }, + log: (m) => logged.push(m), + }), + ); + + // The prose sits beside the interpolation, so a translator working from the export never sees it. + expect(logged.join('\n')).toContain('timestamp.UserActivity'); + expect(logged.join('\n')).toContain('do carry English copy'); + }); + + it('extends the formatter prefixes an SDK excludes', () => { + const dir = makeProject({ + 'src/A.tsx': `t('translationBuilderTopic.notification');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'translationBuilderTopic.notification': '{{value, notification}}', + }), + }); + const jsonOut = path.join(dir, 'en.json'); + + generateI18nKeys( + configFor(dir, { + extraFormatterPrefixes: ['translationBuilderTopic.'], + json: { out: jsonOut }, + }), + ); + + expect(JSON.parse(fs.readFileSync(jsonOut, 'utf8'))).toEqual({}); + }); +}); + +describe('readStringMap', () => { + it('reads through `as const` and `satisfies`', () => { + const dir = makeProject({ + 'src/i18n/runtimeDefaults.ts': `export const runtimeDefaults = {\n 'a.b': 'C',\n} as const satisfies Record;\n`, + }); + + const map = readStringMap({ + exportName: 'runtimeDefaults', + file: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + ts, + }); + + expect(Object.fromEntries(map)).toEqual({ 'a.b': 'C' }); + }); + + it('throws a named error when the file is missing', () => { + expect(() => + readStringMap({ exportName: 'runtimeDefaults', file: '/nope/missing.ts', ts }), + ).toThrow(/could not read the file expected to export `runtimeDefaults`/); + }); + + it('throws when the export is absent', () => { + const dir = makeProject({ + 'src/i18n/runtimeDefaults.ts': `export const other = {};\n`, + }); + + expect(() => + readStringMap({ + exportName: 'runtimeDefaults', + file: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + ts, + }), + ).toThrow(/could not find an exported `runtimeDefaults` object literal/); + }); + + it('throws when an entry is not a string literal', () => { + const dir = makeProject({ + 'src/i18n/runtimeDefaults.ts': `export const runtimeDefaults = { 'a.b': someVar };\n`, + }); + + expect(() => + readStringMap({ + exportName: 'runtimeDefaults', + file: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + ts, + }), + ).toThrow(/must be 'quoted.key': 'string literal'/); + }); +}); diff --git a/test/unit/i18n/Streami18n.test.ts b/test/unit/i18n/Streami18n.test.ts new file mode 100644 index 0000000000..e7cc95009c --- /dev/null +++ b/test/unit/i18n/Streami18n.test.ts @@ -0,0 +1,874 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type DateTimeParserModule, + defaultDateTimeParser, + isDayOrMoment, + RELATIVE_TIME_CATALOG, + Streami18n, + type Streami18nState, + type TDateTimeParserOutput, +} from '../../../src/i18n'; +import { + fixtureRuntimeDefaults, + type FixtureBundledKey, + type FixtureCatalog, +} from './fixtures'; + +const setup = (options: Record = {}) => + new Streami18n({ + logger: () => {}, + runtimeDefaults: fixtureRuntimeDefaults, + ...options, + }); + +describe('Streami18n', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T14:32:00.000Z')); + }); + + describe('formatters', () => { + it('renders a timestamp through its format template', async () => { + const { t } = await setup().init(); + expect( + t('timestamp.MessageTimestamp', { timestamp: '2026-03-13T14:32:00.000Z' }), + ).toBe('2:32 PM'); + }); + + it('renders a calendar timestamp', async () => { + const { t } = await setup().init(); + expect( + t('timestamp.DateSeparator', { timestamp: '2026-03-13T09:00:00.000Z' }), + ).toBe('Today at 9:00 AM'); + }); + + /** + * Regression: a duration has to go through the date library's `.duration()`. Parsing the number as + * a timestamp instead reads 600000 as "ten minutes past the epoch" and renders "57 years ago". + */ + it('renders a duration as a length of time, not a date', async () => { + const { t } = await setup().init(); + expect(t('duration.messageReminder', { milliseconds: 1000 * 60 * 10 })).toBe( + 'in 10 minutes', + ); + }); + + it('leaves no uninterpolated placeholders in any bundled default', async () => { + const { t } = await setup().init(); + for (const key of Object.keys(fixtureRuntimeDefaults)) { + const rendered = t(key as never, { + milliseconds: 1000, + timestamp: '2026-03-13T14:32:00.000Z', + }); + expect(rendered, `${key} left a placeholder`).not.toContain('{{'); + } + }); + + it('accepts a custom formatter and an override of a predefined one', async () => { + const i18n = setup({ + formatters: { + shout: () => (value: unknown) => String(value).toUpperCase(), + }, + }); + i18n.registerTranslation('en', { 'common.cancel.label': '{{ word | shout }}' }); + const { t } = await i18n.init(); + expect(t('common.cancel.label', 'Cancel', { word: 'cancel' })).toBe('CANCEL'); + }); + }); + + describe('plurals and interpolation', () => { + it('selects a plural category and interpolates', async () => { + const i18n = setup(); + i18n.registerTranslation('en', { + 'channel.memberCount.title_one': '{{ count }} member', + 'channel.memberCount.title_other': '{{ count }} members', + }); + const { t } = await i18n.init(); + + expect(t('channel.memberCount.title', { count: 1 })).toBe('1 member'); + expect(t('channel.memberCount.title', { count: 5 })).toBe('5 members'); + }); + + it('interpolates a prose default', async () => { + const { t } = await setup().init(); + expect(t('common.greeting.text', 'Hello {{ name }}', { name: 'Ada' })).toBe( + 'Hello Ada', + ); + }); + }); + + describe('state store', () => { + it('publishes the live translator, so a late subscriber sees it immediately', async () => { + const i18n = setup(); + await i18n.init(); + + const seen: Streami18nState[] = []; + // `subscribe` fires synchronously with the current value — that is what removes the + // callback-registration ordering problem the listener-based API had. + const unsubscribe = i18n.state.subscribe((state) => seen.push(state)); + + expect(seen).toHaveLength(1); + expect(seen[0].initialized).toBe(true); + expect(seen[0].language).toBe('en'); + unsubscribe(); + }); + + it('publishes a language change', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + await i18n.init(); + + const languages: string[] = []; + const unsubscribe = i18n.state.subscribeWithSelector( + (state) => ({ language: state.language }), + ({ language }) => languages.push(language), + ); + + await i18n.setLanguage('de'); + + expect(languages).toEqual(['en', 'de']); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + unsubscribe(); + }); + + it('exposes t, language and initialized as state-backed getters', async () => { + const i18n = setup(); + expect(i18n.initialized).toBe(false); + await i18n.init(); + expect(i18n.initialized).toBe(true); + expect(i18n.currentLanguage).toBe('en'); + expect(typeof i18n.t).toBe('function'); + }); + }); + + describe('init', () => { + it('is idempotent and shares one promise across concurrent callers', async () => { + const i18n = setup(); + const spy = vi.spyOn(i18n.i18nInstance, 'init'); + + const [a, b] = await Promise.all([i18n.init(), i18n.init()]); + await i18n.init(); + + // Two independent consumers (a chat root and an overlay host) both call this. + expect(spy).toHaveBeenCalledTimes(1); + expect(a).toBe(b); + }); + + it('resolves to the same object the state store holds', async () => { + const i18n = setup(); + const state = await i18n.init(); + expect(state).toEqual(i18n.state.getLatestValue()); + }); + }); + + describe('overrideTFunction', () => { + it('applies after init', async () => { + const i18n = setup(); + await i18n.init(); + i18n.overrideTFunction((() => 'OVERRIDDEN') as never); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('OVERRIDDEN'); + }); + + /** The queued-override race in the listener-based design: init must not undo a pre-init override. */ + it('survives a later init', async () => { + const i18n = setup(); + i18n.overrideTFunction((() => 'OVERRIDDEN') as never); + await i18n.init(); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('OVERRIDDEN'); + }); + + it('survives a later setLanguage', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + await i18n.init(); + i18n.overrideTFunction((() => 'OVERRIDDEN') as never); + await i18n.setLanguage('de'); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('OVERRIDDEN'); + }); + }); + + describe('setLanguage', () => { + it('returns nothing, so no caller can cache a translator that goes stale', async () => { + const i18n = setup(); + await i18n.init(); + await expect(i18n.setLanguage('de')).resolves.toBeUndefined(); + }); + + it('takes effect before init and is applied at init', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + await i18n.setLanguage('de'); + const { t } = await i18n.init(); + expect(i18n.currentLanguage).toBe('de'); + expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + }); + }); + + describe('timezone', () => { + it('renders in the configured zone', async () => { + const { t } = await setup({ timezone: 'Asia/Tokyo' }).init(); + // 14:32 UTC is 23:32 in Tokyo. + expect( + t('timestamp.MessageTimestamp', { timestamp: '2026-03-13T14:32:00.000Z' }), + ).toBe('11:32 PM'); + }); + }); + + describe('disableDateTimeTranslations', () => { + it('keeps dates in English for a registered language', async () => { + const i18n = setup({ disableDateTimeTranslations: true, language: 'de' }); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + const { t } = await i18n.init(); + expect( + t('timestamp.DateSeparator', { timestamp: '2026-03-13T09:00:00.000Z' }), + ).toBe('Today at 9:00 AM'); + }); + }); + + describe('logging', () => { + /** `JSON.stringify(error)` renders an Error as `{}`, which is how these used to be logged. */ + it('logs an Error by message rather than as an empty object', async () => { + const logger = vi.fn(); + const i18n = setup({ logger }); + await i18n.init(); + vi.spyOn(i18n.i18nInstance, 'changeLanguage').mockRejectedValue(new Error('boom')); + + await i18n.setLanguage('de'); + + expect(logger).toHaveBeenCalledWith(expect.stringContaining('boom')); + expect(logger).not.toHaveBeenCalledWith(expect.stringContaining('{}')); + }); + }); + + describe('registeredLanguages', () => { + it('excludes a language carrying only the bundled defaults', async () => { + const i18n = setup({ language: 'de' }); + const { t } = await i18n.init(); + + // The dictionary exists -- a bundled formatter key resolves rather than rendering its own + // dotted path... + expect(t('timestamp.MessageTimestamp', { timestamp: new Date(0) })).not.toBe( + 'timestamp.MessageTimestamp', + ); + // ...while `registeredLanguages` stays narrower, which is what makes the G3 warning possible. + expect(i18n.registeredLanguages.has('de')).toBe(false); + expect(i18n.registeredLanguages.has('en')).toBe(true); + }); + + it('includes a language once a dictionary is registered for it', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Abbrechen' } as never); + await i18n.init(); + + expect(i18n.registeredLanguages.has('de')).toBe(true); + }); + }); +}); + +/** + * Region-coded languages. Ported from the React Native SDK's suite, which owned these before the + * runtime moved here. + * + * The hyphen must not be read as a separator of any kind — `keySeparator: false` and + * `nsSeparator: false` are what keep `pt-BR` a single language name rather than a namespace lookup, + * and a base-language dictionary must not shadow the region-coded one. + */ +describe('Streami18n — region-coded languages', () => { + it.each(['pt-BR', 'zh-TW', 'fr-CA', 'es-MX'])( + 'resolves a dictionary for %s', + async (language) => { + const i18n = setup({ language }); + i18n.registerTranslation(language, { + 'fixture.prose': `cancel-${language}`, + } as never); + const { t } = await i18n.init(); + + expect(t('fixture.prose', 'Cancel')).toBe(`cancel-${language}`); + expect(i18n.currentLanguage).toBe(language); + expect(i18n.registeredLanguages.has(language)).toBe(true); + }, + ); + + it('keeps a region-coded language distinct from its base language', async () => { + const i18n = setup({ language: 'pt-BR' }); + i18n.registerTranslation('pt', { 'fixture.prose': 'Cancelar-pt' } as never); + i18n.registerTranslation('pt-BR', { 'fixture.prose': 'Cancelar-ptBR' } as never); + const { t } = await i18n.init(); + + expect(t('fixture.prose', 'Cancel')).toBe('Cancelar-ptBR'); + }); + + it('still layers the bundled defaults under a region-coded language', async () => { + const i18n = setup({ language: 'pt-BR' }); + const { t } = await i18n.init(); + + // Would render as the raw key if runtimeDefaults had not been layered under `pt-BR`. + expect(t('timestamp.MessageTimestamp', { timestamp: new Date(0) })).not.toBe( + 'timestamp.MessageTimestamp', + ); + }); +}); + +/** + * Behaviours the React SDK's suite owned before the runtime moved here. They were asserting this + * module through a thin subclass, so they belong on this side of the boundary — and none of them was + * covered here. + */ +describe('Streami18n — locale and timezone wiring', () => { + it('registers a dayjs locale config supplied at construction', async () => { + const i18n = setup({ + dayjsLocaleConfigForLanguage: { calendar: { sameDay: '[custom today] LT' } }, + language: 'nl', + }); + const { tDateTimeParser } = await i18n.init(); + + const parsed = tDateTimeParser(new Date()); + expect(isDayOrMoment(parsed)).toBe(true); + expect((parsed as { calendar: () => string }).calendar()).toContain('custom today'); + }); + + it('registers a dayjs locale config supplied through registerTranslation', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Hallo' } as never, { + calendar: { sameDay: '[heute um] LT' }, + }); + const { tDateTimeParser } = await i18n.init(); + + expect( + (tDateTimeParser(new Date()) as { calendar: () => string }).calendar(), + ).toContain('heute um'); + }); + + it('defaults to the local timezone', async () => { + const i18n = setup(); + const { tDateTimeParser } = await i18n.init(); + const date = new Date(); + + expect((tDateTimeParser(date) as { format: (t: string) => string }).format('H')).toBe( + date.getHours().toString(), + ); + }); + + it('ignores a timezone when the parser cannot apply one', async () => { + // dayjs without the timezone plugin, i.e. no `.tz` on the module. The option must degrade to local + // time rather than throwing or silently producing a wrong hour. + const parserWithoutTz = Object.assign( + (input?: string | number | Date) => defaultDateTimeParser(input), + { duration: undefined, extend: undefined, locale: undefined }, + ); + const i18n = new Streami18n({ + DateTimeParser: parserWithoutTz as never, + logger: () => {}, + runtimeDefaults: fixtureRuntimeDefaults, + timezone: 'Europe/Prague', + }); + const { tDateTimeParser } = await i18n.init(); + const date = new Date(); + + expect((tDateTimeParser(date) as { format: (t: string) => string }).format('H')).toBe( + date.getHours().toString(), + ); + }); +}); + +describe('Streami18n — registerTranslation does not clobber', () => { + it('keeps a dictionary when setLanguage moves away and back', async () => { + const i18n = setup({ language: 'en' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Hallo' } as never); + i18n.registerTranslation('fr', { 'fixture.prose': 'Bonjour' } as never); + await i18n.init(); + + await i18n.setLanguage('de'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Hallo'); + + await i18n.setLanguage('fr'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Bonjour'); + + // Back again: switching must not have dropped the first dictionary. + await i18n.setLanguage('de'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Hallo'); + }); + + it('keeps a registered dictionary when setLanguage targets an unregistered language', async () => { + const i18n = setup({ language: 'en' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Hallo' } as never); + await i18n.init(); + + await i18n.setLanguage('ja'); + await i18n.setLanguage('de'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Hallo'); + }); +}); + +describe('DateTimeLike', () => { + /** + * Regression: `DateTimeLike` must be declared with **method shorthand**, not + * property-with-function-type. Under `strictFunctionTypes` a function property is checked + * contravariantly, which makes a real Dayjs instance unassignable — its `calendar` takes a narrower + * reference type than a permissive structural signature demands. Method syntax is bivariant, which is + * what duck-typing across dayjs and moment needs. + * + * A type-level assertion, so this fails at `yarn types` rather than at runtime. + */ + it('accepts a real dayjs instance', async () => { + const i18n = setup(); + const { tDateTimeParser } = await i18n.init(); + const parsed = tDateTimeParser('2026-03-13T14:32:00.000Z'); + + // If `DateTimeLike` regresses to property syntax, assigning the parser output fails to compile. + const asDateTimeLike: TDateTimeParserOutput = parsed; + expect(asDateTimeLike).toBeDefined(); + expect(isDayOrMoment(asDateTimeLike)).toBe(true); + }); + + /** + * Regression: a Moment must satisfy `DateTimeLike` too. The docs promise bring-your-own-Moment, and + * it was broken — `startOf` returned `DateTimeLike`, so checking Moment against it required Moment's + * `startOf` return (a Moment) to satisfy `DateTimeLike`, requiring `startOf` again. Method bivariance + * does not break that cycle. Narrow unit unions on `diff`/`startOf` compounded it. + * + * Moment itself is not a dependency here, so this is a hand-written stand-in reproducing the two + * properties that actually broke: narrow unit unions, and a self-returning `startOf`. It is a + * type-level assertion — it fails at `yarn types`, not at runtime. The React Native SDK's suite, + * which passes the real `moment` in, is the end-to-end check. + */ + it('accepts a moment-shaped parser output', () => { + type MomentUnit = 'day' | 'week' | 'month' | 'year'; + type MomentInput = MomentLike | Date | string | number; + type MomentLike = { + calendar(referenceTime?: MomentInput, formats?: Record): string; + diff(other: MomentInput, unit?: MomentUnit): number; + format(template?: string): string; + fromNow(withoutSuffix?: boolean): string; + startOf(unit: MomentUnit): MomentLike; + valueOf(): number; + }; + + const momentLike = {} as MomentLike; + const asDateTimeLike: TDateTimeParserOutput = momentLike; + expect(asDateTimeLike).toBeDefined(); + + // The same failure mode one level up: `DateTimeParserModule`'s members must be method shorthand + // too, or moment's overloaded `locale` is rejected as a contravariant function property. + type MomentModuleLike = ((input?: string | number | Date) => MomentLike) & { + duration(input: number | string): { humanize(withSuffix?: boolean): string }; + locale(language?: string, definition?: Record | null): string; + tz?: unknown; + }; + + const asParserModule: DateTimeParserModule = {} as MomentModuleLike; + expect(asParserModule).toBeDefined(); + }); +}); + +describe('RELATIVE_TIME_CATALOG', () => { + /** + * These keys are rendered by core but declared in each SDK's catalog, so the two have to agree. If a + * key here stops being emitted, an integrator silently loses the ability to translate it — the English + * default still renders, so nothing looks broken. + */ + it('declares exactly the keys the relative-compact formatter renders', async () => { + const i18n = setup({ + runtimeDefaults: { + ...fixtureRuntimeDefaults, + 'timestamp.Relative': + '{{ timestamp | timestampFormatter(relativeCompact: true) }}', + }, + }); + const { t } = await i18n.init(); + const render = (daysAgo: number) => + (t as unknown as (k: string, o: Record) => string)( + 'timestamp.Relative', + { timestamp: new Date(Date.now() - daysAgo * 86_400_000).toISOString() }, + ); + + expect(render(0)).toBe(RELATIVE_TIME_CATALOG['relativeTime.today']); + expect(render(1)).toBe(RELATIVE_TIME_CATALOG['relativeTime.yesterday']); + expect(render(3)).toBe('3d ago'); + expect(render(14)).toBe('2w ago'); + }); + + it('is translatable through a dictionary', async () => { + const i18n = setup({ + language: 'de', + runtimeDefaults: { + ...fixtureRuntimeDefaults, + 'timestamp.Relative': + '{{ timestamp | timestampFormatter(relativeCompact: true) }}', + }, + }); + i18n.registerTranslation('de', { + 'relativeTime.daysAgo_other': 'vor {{ count }} Tagen', + 'relativeTime.today': 'Heute', + } as never); + const { t } = await i18n.init(); + const render = (daysAgo: number) => + (t as unknown as (k: string, o: Record) => string)( + 'timestamp.Relative', + { timestamp: new Date(Date.now() - daysAgo * 86_400_000).toISOString() }, + ); + + expect(render(0)).toBe('Heute'); + expect(render(3)).toBe('vor 3 Tagen'); + }); +}); + +/** + * A dayjs module the integrator supplied gets the plugins too. + * + * `ensureDayjsPlugins()` used to always extend *our* `dayjs` import, whatever module was passed in. + * With a second physical copy of dayjs -- the normal case for an integrator who imports their own + * locales -- that left theirs plugin-less, and the failure was silent and total: `format('LT')` echoed + * the literal token back and `.calendar()` was simply absent. + */ +describe('Streami18n — an integrator-supplied dayjs module', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + /** A stand-in for a second dayjs copy: records what was registered on it. */ + const makeUnextendedDayjsLike = () => { + const registered: unknown[] = []; + const parser = ((input?: unknown) => ({ + calendar: () => 'calendar', + diff: () => 0, + format: (template?: string) => `formatted:${template ?? ''}:${String(input)}`, + locale: () => parser(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule & { extend: (plugin: unknown) => unknown }; + + parser.extend = (plugin: unknown) => { + registered.push(plugin); + return parser; + }; + + return { parser, registered }; + }; + + it('registers the plugins on the supplied module, not only on ours', () => { + const { parser, registered } = makeUnextendedDayjsLike(); + + setup({ DateTimeParser: parser }); + + // The eight the formatters need: updateLocale, utc, timezone, localizedFormat, calendar, + // localeData, relativeTime, duration. + expect(registered).toHaveLength(8); + expect(registered.every((plugin) => typeof plugin === 'function')).toBe(true); + }); + + it('does not re-register on a second instance sharing the module', () => { + const { parser, registered } = makeUnextendedDayjsLike(); + + setup({ DateTimeParser: parser }); + setup({ DateTimeParser: parser }); + + expect(registered).toHaveLength(8); + }); + + it('leaves a module without `extend` alone rather than throwing', () => { + const momentish = ((input?: unknown) => ({ + diff: () => 0, + format: () => String(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule; + + expect(() => setup({ DateTimeParser: momentish })).not.toThrow(); + }); +}); + +/** + * `Date.parse` returns `0` for the epoch, so `!Date.parse(value)` classified a valid timestamp as junk. + */ +describe('Streami18n — the Unix epoch is a valid timestamp', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('formats an epoch timestamp string rather than rendering nothing', async () => { + const { t } = await setup().init(); + + expect( + t('timestamp.MessageTimestamp', { timestamp: '1970-01-01T00:00:00.000Z' }), + ).toBe('12:00 AM'); + }); + + it('still renders nothing for a string that is genuinely not a date', async () => { + const { t } = await setup().init(); + + expect(t('timestamp.MessageTimestamp', { timestamp: 'not a date' })).toBe(''); + }); +}); + +/** + * Formatter factories run once, during `init()`, and are never re-run on a language change -- so the + * context has to expose accessors rather than the values it had at initialization. + */ +describe('Streami18n — the formatter context follows the active language', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('reports the language in force at call time, not at init time', async () => { + const seen: string[] = []; + const i18n = setup({ + formatters: { + languageProbe: + ({ currentLanguage }: { currentLanguage: string }) => + () => { + seen.push(currentLanguage); + return currentLanguage; + }, + }, + translationsForLanguage: { + 'fixture.probe': '{{ value | languageProbe }}', + }, + }); + i18n.registerTranslation('de', { + 'fixture.probe': '{{ value | languageProbe }}', + } as never); + + const { t } = await i18n.init(); + (t as (key: string, options: object) => string)('fixture.probe', { value: 'x' }); + + await i18n.setLanguage('de'); + const after = i18n.state.getLatestValue().t as unknown as ( + key: string, + options: object, + ) => string; + after('fixture.probe', { value: 'x' }); + + expect(seen).toEqual(['en', 'de']); + }); +}); + +/** + * A failed language switch must not leave the store advertising a language i18next never adopted -- + * `tDateTimeParser` reads it on every call, so dates would format in a locale whose copy is absent. + */ +describe('Streami18n — a failed setLanguage rolls back', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('restores the previous language when changeLanguage rejects', async () => { + const logger = vi.fn(); + const i18n = setup({ logger }); + await i18n.init(); + expect(i18n.currentLanguage).toBe('en'); + + vi.spyOn(i18n.i18nInstance, 'changeLanguage').mockRejectedValue(new Error('nope')); + await i18n.setLanguage('de'); + + expect(i18n.currentLanguage).toBe('en'); + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('failed to set language: nope'), + ); + }); + + it('keeps the new language when the switch succeeds', async () => { + const i18n = setup(); + await i18n.init(); + await i18n.setLanguage('de'); + + expect(i18n.currentLanguage).toBe('de'); + }); +}); + +/** + * A failed `init()` leaves the instance degraded but *safe*. + * + * Neither UI SDK awaits `init()`, so it must never reject. And `initialized` must stay false, because + * it means "i18next is usable" -- `registerTranslation` and `setLanguage` both branch on it, and a + * `true` there sends them into an instance whose own init rejected. + */ +describe('Streami18n — a failed init()', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + const failing = (logger = () => {}) => { + const i18n = setup({ logger }); + vi.spyOn(i18n.i18nInstance, 'init').mockRejectedValue(new Error('i18next exploded')); + return i18n; + }; + + it('resolves rather than rejecting, and reports the failure', async () => { + const logger = vi.fn(); + const i18n = failing(logger); + + await expect(i18n.init()).resolves.toBeDefined(); + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('initialization failed: i18next exploded'), + ); + }); + + it('leaves `initialized` false', async () => { + const i18n = failing(); + const state = await i18n.init(); + + expect(state.initialized).toBe(false); + expect(i18n.initialized).toBe(false); + }); + + it('keeps rendering the inline English copy', async () => { + const i18n = failing(); + const { t } = await i18n.init(); + + expect(t('fixture.prose', 'Cancel')).toBe('Cancel'); + }); + + /** The bug this guards: `addResources` on a dead instance threw out of `registerTranslation`. */ + it('does not throw from registerTranslation or setLanguage', async () => { + const i18n = failing(); + await i18n.init(); + + expect(() => + i18n.registerTranslation('de', { 'fixture.prose': 'Abbrechen' } as never), + ).not.toThrow(); + await expect(i18n.setLanguage('de')).resolves.toBeUndefined(); + }); + + /** + * The one path that escapes, recorded rather than guarded. + * + * The logger is called from the `catch`, so a logger that throws rejects out of `init()`. Both UI + * SDKs call `init()` without awaiting it, so that surfaces as an unhandled rejection — worth knowing + * before supplying a logger that can throw. + */ + it('rejects when the logger itself throws', async () => { + const i18n = failing(() => { + throw new Error('logger exploded'); + }); + + await expect(i18n.init()).rejects.toThrow('logger exploded'); + expect(i18n.initialized).toBe(false); + }); +}); + +describe('Streami18n — init() memoization', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('hands the same promise to concurrent callers on the happy path', async () => { + const i18n = setup(); + const first = i18n.init(); + + expect(i18n.init()).toBe(first); + await first; + expect(i18n.init()).toBe(first); + }); +}); + +/** + * Locale configuration has to reach the module that formats the dates. + * + * `ensureDayjsPlugins` was fixed to extend a supplied module, but locale registration still wrote to + * core's own dayjs — so a second physical copy got the plugins and none of the `calendar` wording, and + * `dayjsLocaleConfigForLanguage` was silently inert. + */ +describe('Streami18n — locale config on a supplied dayjs module', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + /** A stand-in for a second dayjs copy: its own `Ls` registry, and it records writes. */ + const makeDayjsCopy = () => { + const registered: Array<{ method: string; name: string }> = []; + const parser = ((input?: unknown) => ({ + calendar: () => 'calendar', + diff: () => 0, + format: () => String(input), + locale: () => parser(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule & Record; + + parser.extend = () => parser; + parser.Ls = { en: {} }; + parser.locale = (preset: { name: string }) => { + registered.push({ method: 'locale', name: preset.name }); + (parser.Ls as Record)[preset.name] = preset; + return parser; + }; + parser.updateLocale = (name: string) => { + registered.push({ method: 'updateLocale', name }); + return parser; + }; + + return { parser, registered }; + }; + + const calendar = { + lastDay: '[Gestern]', + lastWeek: 'dddd', + nextDay: '[Morgen]', + nextWeek: 'dddd [um] LT', + sameDay: '[Heute]', + sameElse: 'L', + }; + + it('registers a constructor-supplied locale config on that module', () => { + const { parser, registered } = makeDayjsCopy(); + + setup({ + DateTimeParser: parser, + dayjsLocaleConfigForLanguage: { calendar }, + language: 'de', + }); + + expect(registered).toEqual([{ method: 'locale', name: 'de' }]); + }); + + it('registers a registerTranslation locale config on that module', async () => { + const { parser, registered } = makeDayjsCopy(); + const i18n = setup({ DateTimeParser: parser }); + + i18n.registerTranslation('de', { 'fixture.prose': 'Abbrechen' } as never, { + calendar, + }); + // Locale configs are applied when the language becomes active, which needs an initialized + // instance -- `setLanguage` returns at its `initialized` guard otherwise. + await i18n.init(); + await i18n.setLanguage('de'); + + expect(registered.some(({ name }) => name === 'de')).toBe(true); + }); + + it('consults the supplied module when deciding whether a locale exists', () => { + const logger = vi.fn(); + const { parser } = makeDayjsCopy(); + + // `de` is absent from the supplied module's registry, so the missing-locale warning must fire -- + // it used to be suppressed unconditionally for any custom parser. + setup({ DateTimeParser: parser, language: 'de', logger }); + + expect(logger).toHaveBeenCalledWith( + expect.stringContaining("no dayjs locale is registered for 'de'"), + ); + }); + + it('reports rather than silently dropping a locale config for a non-dayjs parser', () => { + const logger = vi.fn(); + const momentish = ((input?: unknown) => ({ + diff: () => 0, + format: () => String(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule; + + setup({ + DateTimeParser: momentish, + dayjsLocaleConfigForLanguage: { calendar }, + language: 'de', + logger, + }); + + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('DateTimeParser is not dayjs, so it cannot be applied'), + ); + }); +}); diff --git a/test/unit/i18n/Streami18nGuarantees.test.ts b/test/unit/i18n/Streami18nGuarantees.test.ts new file mode 100644 index 0000000000..0b63a09982 --- /dev/null +++ b/test/unit/i18n/Streami18nGuarantees.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { asDynamicKey, Streami18n } from '../../../src/i18n'; +import { + FORMATTER_KEY, + fixtureRuntimeDefaults, + type FixtureBundledKey, + type FixtureCatalog, +} from './fixtures'; + +/** + * The three behavioural guarantees the shared i18n architecture has to hold. + * + * Ported from `stream-chat-react-native`'s `Streami18nGuarantees.test.ts`, where each one was written + * against a real bug found reviewing the web implementation. They live here now because they describe + * `Streami18n` behaviour rather than anything React- or RN-specific, which means a third SDK cannot + * regress them and neither UI SDK has to keep its own copy. + * + * G1 — every language is layered over the SDK's bundled defaults, however it was selected. + * G2 — a partial dictionary is safe: unsupplied keys render English, never a raw dotted path. + * G3 — selecting an unregistered language warns and continues; it must not silently reset to `en`. + */ + +type Dictionary = Partial>; + +/** Core has no catalog, so every instance is handed the fixture's bundled defaults. */ +const setup = (options: Record = {}) => + new Streami18n({ + logger: () => {}, + runtimeDefaults: fixtureRuntimeDefaults, + ...options, + }); + +describe('G1 — bundled defaults are layered under every language', () => { + it('applies to a language selected via the `language` option', async () => { + const i18n = setup({ language: 'de' }); + const { t } = await i18n.init(); + + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + }); + + it('applies to a language added with registerTranslation', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + await i18n.setLanguage('de'); + const { t } = await i18n.init(); + + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + }); + + it('applies to `en` when no dictionary is supplied at all', async () => { + const i18n = setup(); + const { t } = await i18n.init(); + + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + }); + + it('survives registerTranslation for a language that already had one', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + i18n.registerTranslation('de', { 'common.loading.text': 'Lädt...' }); + const { t } = await i18n.init(); + + // Registering twice must accumulate, and must not knock out the bundled formatter keys. + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + expect(t('common.loading.text', 'Loading...')).toBe('Lädt...'); + }); + + it('never lets an integrator dictionary shadow a bundled key by omission', async () => { + const i18n = setup({ + language: 'de', + translationsForLanguage: { 'common.cancel.label': 'Abbrechen' }, + }); + const { t } = await i18n.init(); + + expect(t(FORMATTER_KEY)).toBe(fixtureRuntimeDefaults[FORMATTER_KEY]); + }); +}); + +describe('G2 — a partial dictionary renders English, not a dotted path', () => { + it('renders the inline default for a key the dictionary does not supply', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + const { t } = await i18n.init(); + + expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); + }); + + it('renders the supplied translation when the dictionary does supply it', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + const { t } = await i18n.init(); + + expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + }); + + it('never renders a raw dotted key for a prose key', async () => { + const i18n = setup({ language: 'de' }); + const { t } = await i18n.init(); + + const rendered = t('common.loading.text', 'Loading...'); + expect(rendered).not.toMatch(/^[a-z][a-zA-Z]*(\.[a-zA-Z]+)+$/); + }); + + it('does not let an integrator parseMissingKeyHandler blank out prose keys', async () => { + // Every prose key looks "missing" to i18next — it resolves from the inline default, not from a + // resource bundle — and the handler's return value replaces the rendered string. An unguarded + // handler therefore blanks out most of the UI. + const i18n = setup({ i18nextConfigOverrides: { parseMissingKeyHandler: () => '' } }); + const { t } = await i18n.init(); + + expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); + }); + + it('still reports a genuinely missing key to an integrator handler', async () => { + const parseMissingKeyHandler = vi.fn(() => 'MISSING'); + const i18n = setup({ i18nextConfigOverrides: { parseMissingKeyHandler } }); + const { t } = await i18n.init(); + + // No inline default and not in runtimeDefaults — this one really is missing. + expect(t(asDynamicKey('nothing.declares.this'))).toBe('MISSING'); + expect(parseMissingKeyHandler).toHaveBeenCalled(); + }); +}); + +describe('G3 — an unregistered language warns and continues', () => { + it('does not silently reset the language to en', async () => { + const i18n = setup({ language: 'de' }); + await i18n.init(); + + expect(i18n.currentLanguage).toBe('de'); + }); + + it('warns that the language has no dictionary', async () => { + const logger = vi.fn(); + const i18n = setup({ language: 'de', logger }); + await i18n.init(); + + // Specifically the *translation* warning — not an unrelated dayjs "locale config for de does not + // exist" message, which would let this pass for the wrong reason. + expect(logger).toHaveBeenCalledWith(expect.stringContaining('registerTranslation')); + expect(logger).toHaveBeenCalledWith( + expect.stringMatching(/no translation dictionary is registered/i), + ); + }); + + it('keeps the language after setLanguage to an unregistered one', async () => { + const i18n = setup(); + await i18n.init(); + await i18n.setLanguage('de'); + + expect(i18n.currentLanguage).toBe('de'); + }); + + it('still renders English copy in the unregistered language', async () => { + const i18n = setup({ language: 'de' }); + const { t } = await i18n.init(); + + expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); + }); +}); + +describe('G3 — when the warning fires', () => { + /** + * Timing matters as much as the message. `registerTranslation()` legitimately runs *after* + * construction — it is the documented way to add a language — so warning in the constructor fires for + * every integrator doing the normal thing, and trains them to ignore it. + */ + it('does not warn at construction, before registerTranslation has had a chance to run', () => { + const logger = vi.fn(); + + new Streami18n({ + language: 'de', + logger, + runtimeDefaults: fixtureRuntimeDefaults, + }); + + expect(logger).not.toHaveBeenCalledWith( + expect.stringMatching(/no translation dictionary is registered/i), + ); + }); + + it('warns exactly once, at init, when no dictionary ever arrives', async () => { + const logger = vi.fn(); + const i18n = setup({ language: 'de', logger }); + + await i18n.init(); + + const warnings = logger.mock.calls.filter(([message]) => + /no translation dictionary is registered/i.test(String(message)), + ); + expect(warnings).toHaveLength(1); + }); + + it('does not warn when a dictionary was registered before init', async () => { + const logger = vi.fn(); + const i18n = setup({ language: 'de', logger }); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + + await i18n.init(); + + expect(logger).not.toHaveBeenCalledWith( + expect.stringMatching(/no translation dictionary is registered/i), + ); + }); +}); diff --git a/test/unit/i18n/TranslationBuilder.test.ts b/test/unit/i18n/TranslationBuilder.test.ts new file mode 100644 index 0000000000..1e7e700b6a --- /dev/null +++ b/test/unit/i18n/TranslationBuilder.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Streami18n, TranslationTopic } from '../../../src/i18n'; +import type { Translator } from '../../../src/i18n'; + +/** + * i18next post-processors are configured **globally** (`postProcess: [...]`), so a topic is invoked for + * every key, not only the one named after it. A topic therefore has to recognise its own calls and pass + * everything else through untouched — which is what `options.kind` does here, and what a real topic does + * by checking for the object it dispatches on. + */ +class KindTopic extends TranslationTopic<{ kind?: string }> { + translate = (value: string, key: string, options: { kind?: string }) => { + if (!options.kind) return value; + const chosen = this.translators.get(options.kind) ?? this.translators.get('*'); + return chosen?.({ key, options, t: this.i18next.t, value }) ?? value; + }; +} + +/** The key's own value is only a fallback: a topic that handles the call replaces it wholesale. */ +const FALLBACK = 'FALLBACK'; + +const setup = (options: Record = {}) => + new Streami18n({ + logger: () => {}, + runtimeDefaults: { 'translationBuilderTopic.kind': FALLBACK }, + translationBuilderTopics: { kind: KindTopic }, + ...options, + }); + +const render = ( + t: unknown, + options: Record = { kind: 'shout', value: 'hello' }, +) => + (t as (k: string, o?: Record) => string)( + 'translationBuilderTopic.kind', + options, + ); + +describe('TranslationBuilder', () => { + it('runs the topic as an i18next post-processor', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { + shout: ({ options }) => String(options.value).toUpperCase(), + }); + const { t } = await i18n.init(); + + expect(render(t)).toBe('HELLO'); + }); + + /** + * The reason the registration buffer exists: topics are only constructed during `init()`, but an + * integrator registers translators against the instance they just built. Without buffering, anything + * registered first is silently dropped. + */ + it('flushes translators registered before init', async () => { + const i18n = setup(); + expect(i18n.translationBuilder.getTopic('kind')).toBeUndefined(); + i18n.translationBuilder.registerTranslators('kind', { + '*': ({ options }) => `[${options.value}]`, + }); + + const { t } = await i18n.init(); + + expect(i18n.translationBuilder.getTopic('kind')).toBeDefined(); + expect(render(t, { kind: 'anything', value: 'x' })).toBe('[x]'); + }); + + /** + * Removal has to reach the buffer too, not just a live topic. + * + * Registering and then removing before `init()` is a real sequence — an integrator swapping one + * translator out during setup — and if `removeTranslators` only looked at constructed topics, the + * removed translator would come back when the buffer flushed. Ported from the React SDK's suite, + * which owned this case before the plumbing moved here. + */ + it('removes a buffered translator before the topic exists', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { + quiet: ({ options }) => String(options.value).toLowerCase(), + shout: ({ options }) => String(options.value).toUpperCase(), + }); + i18n.translationBuilder.removeTranslators('kind', ['shout']); + + const { t } = await i18n.init(); + + // `quiet` survived the flush; `shout` did not come back with it. + expect(render(t, { kind: 'quiet', value: 'HeLLo' })).toBe('hello'); + expect(render(t, { kind: 'shout', value: 'HeLLo' })).toBe(FALLBACK); + }); + + it('lets a later registration override an earlier one', async () => { + const i18n = setup(); + const { t } = await i18n.init(); + + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'first' }); + expect(render(t)).toBe('first'); + + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'second' }); + expect(render(t)).toBe('second'); + }); + + it('falls back to the key value when a translator declines', async () => { + const i18n = setup(); + const declines: Translator<{ kind?: string }> = () => null; + i18n.translationBuilder.registerTranslators('kind', { '*': declines as Translator }); + const { t } = await i18n.init(); + + expect(render(t)).toBe(FALLBACK); + }); + + /** A topic must not touch keys that are not its own, since post-processing is global. */ + it('passes through calls it does not recognise', async () => { + const i18n = setup({ + runtimeDefaults: { + 'timestamp.Unrelated': '{{ timestamp | timestampFormatter(format: LT) }}', + 'translationBuilderTopic.kind': FALLBACK, + }, + }); + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'HANDLED' }); + const { t } = await i18n.init(); + + const unrelated = (t as (k: string, o?: Record) => string)( + 'timestamp.Unrelated', + { timestamp: '2026-03-13T14:32:00.000Z' }, + ); + expect(unrelated).toBe('2:32 PM'); + }); + + it('removeTranslators drops a registered translator', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'handled' }); + const { t } = await i18n.init(); + expect(render(t)).toBe('handled'); + + i18n.translationBuilder.removeTranslators('kind', ['*']); + + expect(render(t)).toBe(FALLBACK); + }); + + it('disableTopic turns the post-processor into a pass-through', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'handled' }); + const { t } = await i18n.init(); + expect(render(t)).toBe('handled'); + + i18n.translationBuilder.disableTopic('kind'); + + expect(i18n.translationBuilder.getTopic('kind')).toBeUndefined(); + expect(render(t)).toBe(FALLBACK); + }); + + it('registerTopic is idempotent', async () => { + const i18n = setup(); + await i18n.init(); + const first = i18n.translationBuilder.getTopic('kind'); + + i18n.translationBuilder.registerTopic('kind', KindTopic); + + expect(i18n.translationBuilder.getTopic('kind')).toBe(first); + }); + + it('configures no post-processing when no topics are supplied', async () => { + const i18n = new Streami18n({ logger: vi.fn(), runtimeDefaults: {} }); + const { t } = await i18n.init(); + + expect((t as (k: string, d: string) => string)('common.thing', 'Thing')).toBe( + 'Thing', + ); + }); +}); diff --git a/test/unit/i18n/TranslationStore.test.ts b/test/unit/i18n/TranslationStore.test.ts new file mode 100644 index 0000000000..0fb921d477 --- /dev/null +++ b/test/unit/i18n/TranslationStore.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_LANGUAGE, TranslationStore } from '../../../src/i18n'; + +/** + * The layering rule, tested directly. + * + * It used to be reachable only through a fully initialized `Streami18n`, which meant asserting on it + * required i18next, dayjs and an async `init()` — so the rule that actually matters (bundled defaults + * survive a partial dictionary) was only ever verified as a side effect of rendering. + */ +const BUNDLED = { + 'a11y.close.label': 'Close', + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: LT) }}', +}; + +describe('TranslationStore', () => { + it('starts with English registered and nothing else', () => { + const store = new TranslationStore(BUNDLED); + + expect(store.registeredLanguages.has(DEFAULT_LANGUAGE)).toBe(true); + expect([...store.registeredLanguages]).toEqual([DEFAULT_LANGUAGE]); + // No dictionary is created until one is asked for. + expect(store.languages).toEqual([]); + }); + + it('layers the bundled defaults under a language nobody registered', () => { + const store = new TranslationStore(BUNDLED); + + expect(store.ensure('de')).toEqual(BUNDLED); + expect(store.languages).toEqual(['de']); + // Present, but not *registered* -- the distinction the unregistered-language warning needs. + expect(store.isRegistered('de')).toBe(false); + }); + + /** Guarantee G1: a partial dictionary must not knock out the bundled formatter keys. */ + it('keeps the bundled keys when a partial dictionary is registered', () => { + const store = new TranslationStore(BUNDLED); + + const merged = store.register('de', { 'a11y.close.label': 'Schließen' }); + + expect(merged['a11y.close.label']).toBe('Schließen'); + expect(merged['timestamp.MessageTimestamp']).toBe( + '{{ timestamp | timestampFormatter(format: LT) }}', + ); + expect(store.isRegistered('de')).toBe(true); + }); + + it('accumulates repeated registrations for one language', () => { + const store = new TranslationStore(BUNDLED); + + store.register('de', { 'a11y.close.label': 'Schließen' }); + const merged = store.register('de', { 'fixture.prose': 'Abbrechen' }); + + expect(merged['a11y.close.label']).toBe('Schließen'); + expect(merged['fixture.prose']).toBe('Abbrechen'); + }); + + it('lets a later registration win over an earlier one', () => { + const store = new TranslationStore(BUNDLED); + + store.register('de', { 'a11y.close.label': 'Erste' }); + const merged = store.register('de', { 'a11y.close.label': 'Zweite' }); + + expect(merged['a11y.close.label']).toBe('Zweite'); + }); + + it('lets an integrator override a bundled key', () => { + const store = new TranslationStore(BUNDLED); + + const merged = store.register('en', { + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: HH:mm) }}', + }); + + expect(merged['timestamp.MessageTimestamp']).toBe( + '{{ timestamp | timestampFormatter(format: HH:mm) }}', + ); + }); + + it('does not mutate the bundled defaults it was handed', () => { + const runtimeDefaults = { ...BUNDLED }; + const store = new TranslationStore(runtimeDefaults); + + store.register('de', { 'a11y.close.label': 'Schließen' }); + store.ensure('fr'); + + expect(runtimeDefaults).toEqual(BUNDLED); + }); + + it('keeps a region-coded language separate from its base', () => { + const store = new TranslationStore(BUNDLED); + + store.register('pt', { 'fixture.prose': 'pt' }); + const ptBR = store.register('pt-BR', { 'fixture.prose': 'pt-BR' }); + + expect(ptBR['fixture.prose']).toBe('pt-BR'); + expect(store.entries()).toHaveLength(2); + }); + + it('ensure() is idempotent and preserves what was registered', () => { + const store = new TranslationStore(BUNDLED); + + store.register('de', { 'fixture.prose': 'Abbrechen' }); + const ensured = store.ensure('de'); + + expect(ensured['fixture.prose']).toBe('Abbrechen'); + expect(store.languages).toEqual(['de']); + expect(store.isRegistered('de')).toBe(true); + }); + + it('works with no bundled defaults at all', () => { + const store = new TranslationStore(); + + expect(store.ensure('de')).toEqual({}); + expect(store.register('de', { 'fixture.prose': 'Abbrechen' })).toEqual({ + 'fixture.prose': 'Abbrechen', + }); + }); +}); diff --git a/test/unit/i18n/fixtures.ts b/test/unit/i18n/fixtures.ts new file mode 100644 index 0000000000..4a99318828 --- /dev/null +++ b/test/unit/i18n/fixtures.ts @@ -0,0 +1,47 @@ +/** + * A synthetic translation catalog standing in for a UI SDK's generated `keys.ts`. + * + * Core ships no catalog of its own — each UI SDK generates one from its own `t()` call sites — so the + * behavioural suites here run against this fixture instead. That is deliberately better than testing + * through a real 400+ key catalog: every key *shape* the type layer and the runtime have to handle is + * present and named, so a shape that stops working fails a test rather than hiding among hundreds of + * structurally identical prose keys. + */ +export type FixtureCatalog = { + // plain prose + 'common.cancel.label': 'Cancel'; + 'common.loading.text': 'Loading...'; + // prose carrying interpolation + 'common.greeting.text': 'Hello {{ name }}'; + // a plural pair, single variable + 'channel.memberCount.title_one': '{{ count }} member'; + 'channel.memberCount.title_other': '{{ count }} members'; + // a plural pair with a second variable, to exercise multi-var inference + 'poll.voteCount.title_one': '{{ count }} vote in {{ pollName }}'; + 'poll.voteCount.title_other': '{{ count }} votes in {{ pollName }}'; + // formatter expressions — bundled, no inline default anywhere + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: LT) }}'; + 'timestamp.DateSeparator': '{{ timestamp | timestampFormatter(calendar: true) }}'; + 'duration.messageReminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}'; + // ordinary prose that nonetheless reaches t() as a runtime value, so it is bundled + 'a11y.close.label': 'Close'; +}; + +/** The SDK-bundled keys with no inline default at any call site. */ +export type FixtureBundledKey = 'a11y.close.label'; + +/** + * The only translation data a UI SDK ships: keys that cannot carry an inline `defaultValue`. + * + * If these are not layered under every language, a formatter key renders as its own dotted path and + * a timestamp renders as an unformatted ISO string — which is guarantee G1 below. + */ +export const fixtureRuntimeDefaults: Record = { + 'a11y.close.label': 'Close', + 'duration.messageReminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}', + 'timestamp.DateSeparator': '{{ timestamp | timestampFormatter(calendar: true) }}', + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: LT) }}', +}; + +/** A formatter key: bundled data, no inline default. Renders as the literal key if G1 is broken. */ +export const FORMATTER_KEY = 'timestamp.MessageTimestamp'; diff --git a/test/unit/i18n/getDateString.test.ts b/test/unit/i18n/getDateString.test.ts new file mode 100644 index 0000000000..6ff4dd237c --- /dev/null +++ b/test/unit/i18n/getDateString.test.ts @@ -0,0 +1,442 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createDefaultTranslatorFunction, + defaultDateTimeParser, + getCalendarDateStringForA11y, + getDateString, + Streami18n, +} from '../../../src/i18n'; +import type { TDateTimeParserInput } from '../../../src/i18n'; + +/** + * A key whose formatter expression carries its own arguments — the shape a UI SDK actually ships. + * + * Multi-argument, because that is where the bug this suite pins was: a single argument masked it. + */ +const KEY = 'timestamp.MessageTimestamp'; +const KEY_VALUE = '{{ timestamp | timestampFormatter(calendar: false; format: HH:mm) }}'; +const AT = '2019-04-03T14:42:47.087Z'; + +/** `Streami18n` with the catalog left open, so a test can supply an arbitrary formatter expression. */ +const StreamI18nForLogger = Streami18n as unknown as new (options: { + logger: (message?: string) => void; + runtimeDefaults: Record; +}) => Streami18n; + +const setup = async (runtimeDefaults: Record = { [KEY]: KEY_VALUE }) => { + const i18n = new Streami18n({ logger: () => {}, runtimeDefaults }); + return i18n.init(); +}; + +describe('getDateString', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2019-04-03T18:00:00.000Z')); + }); + + /** + * Regression, and the reason this file exists. + * + * The options `getDateString` forwards reach i18next as interpolation values and are merged *over* + * the arguments the key's own formatter expression declares. Forwarding `format: undefined` — which + * is the normal case, since callers are components passing optional props straight through — + * therefore overrode `timestampFormatter(format: HH:mm)` with nothing, and the timestamp rendered as + * a raw ISO string (`2019-04-03T14:42:47+00:00`) instead of `14:42`. + * + * It fails only through this path: calling `t(KEY, { timestamp })` directly renders correctly, which + * is what made it invisible in the unit tests for the formatter itself. + */ + it('does not let undefined options override the key’s own formatter arguments', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ + calendar: undefined, + calendarFormats: undefined, + format: undefined, + formatDate: undefined, + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe('14:42'); + }); + + it('still lets a caller override the key’s arguments when it supplies them', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ + format: 'YYYY', + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe('2019'); + }); + + it('renders the same through t() and through getDateString', async () => { + const { t, tDateTimeParser } = await setup(); + const direct = (t as unknown as (k: string, o: Record) => string)( + KEY, + { + timestamp: AT, + }, + ); + + expect( + getDateString({ + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe(direct); + }); + + it('lets an integrator formatDate win over everything', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ + formatDate: () => 'CUSTOM', + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe('CUSTOM'); + }); + + it('falls through to the parser when the key resolves to nothing', async () => { + const { t, tDateTimeParser } = await setup({}); + + expect( + getDateString({ + format: 'YYYY', + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: 'timestamp.NotDeclared', + }), + ).toBe('2019'); + }); + + it('returns null for a missing or unparseable timestamp', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ messageCreatedAt: undefined, t: t as never, tDateTimeParser }), + ).toBe(null); + expect( + getDateString({ messageCreatedAt: 'not a date', t: t as never, tDateTimeParser }), + ).toBe(null); + }); +}); + +describe('getDateString — options handed to a custom formatter', () => { + /** + * Integrators override a `timestamp.*` key with their own formatter and read `options.timestamp`, + * which they expect to be a `Date`. Forwarding the raw value breaks them with + * `timestamp.toISOString is not a function`. + */ + it('passes the timestamp as a Date', async () => { + const seen: Record[] = []; + const i18n = new Streami18n({ + logger: () => {}, + runtimeDefaults: { [KEY]: KEY_VALUE }, + formatters: { + timestampFormatter: () => (v, l, o) => { + seen.push(o as never); + return 'SPY'; + }, + }, + }); + const { t, tDateTimeParser } = await i18n.init(); + + getDateString({ + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }); + + expect(seen[0].timestamp).toBeInstanceOf(Date); + expect((seen[0].timestamp as Date).toISOString()).toBe(AT); + }); +}); + +describe('timestampFormatter — nothing renderable', () => { + /** + * `null` used to render the literal text "null" and an unparseable string "Invalid Date". Both are + * junk a user can see, and both reached the UI because the formatter is a separate path from + * `getDateString`, which has always guarded this. + */ + // `undefined` is deliberately absent: i18next skips interpolation when the value is undefined, so it + // never reaches the formatter and the raw expression comes through. That is unchanged behaviour, and a + // sign the option name is misspelled at the call site. + it.each([ + ['null', null], + ['an unparseable string', 'not a date'], + ['an empty string', ''], + ])('renders empty for %s', async (_label, value) => { + const i18n = new Streami18n({ + logger: () => {}, + runtimeDefaults: { [KEY]: KEY_VALUE }, + }); + const { t } = await i18n.init(); + + expect( + (t as unknown as (k: string, o: Record) => string)(KEY, { + timestamp: value, + }), + ).toBe(''); + }); +}); + +/** + * The React Native SDK's a11y variant, which is deliberately *not* the same function as + * `getDateStringForA11y`. It keeps the locale's relative wording and substitutes `LL` only into + * `sameElse`, because iOS VoiceOver reads a numeric date character by character. Consolidating the two + * SDKs' i18n layers initially collapsed both into the `LLLL` variant, which would have silently + * changed every announced date label in the RN SDK. + */ +describe('getCalendarDateStringForA11y', () => { + const parser = (input?: TDateTimeParserInput) => defaultDateTimeParser(input); + + // The suites above freeze the clock to `AT`; these assertions are about the distance between now and + // the timestamp, so they need the real one back. + beforeEach(() => { + vi.useRealTimers(); + }); + + it('keeps relative wording for a recent date', () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + expect( + getCalendarDateStringForA11y({ + messageCreatedAt: yesterday, + tDateTimeParser: parser, + }), + ).toBe('Yesterday'); + }); + + it('spells an older date out rather than leaving it numeric', () => { + expect( + getCalendarDateStringForA11y({ messageCreatedAt: AT, tDateTimeParser: parser }), + ).toBe('April 3, 2019'); + }); + + it('applies calendarFormatOverrides over the locale defaults', () => { + const now = new Date(); + // What ChannelPreviewStatus does: show the time, not the word "Today". + const rendered = getCalendarDateStringForA11y({ + calendarFormatOverrides: { sameDay: 'LT' }, + messageCreatedAt: now, + tDateTimeParser: parser, + }); + expect(rendered).not.toBe('Today'); + expect(rendered).toMatch(/\d{1,2}:\d{2}/); + }); + + it('returns undefined rather than a malformed date when there is nothing to announce', () => { + expect(getCalendarDateStringForA11y({ tDateTimeParser: parser })).toBeUndefined(); + expect( + getCalendarDateStringForA11y({ + messageCreatedAt: 'not a date', + tDateTimeParser: parser, + }), + ).toBeUndefined(); + expect(getCalendarDateStringForA11y({ messageCreatedAt: AT })).toBeUndefined(); + }); +}); + +/** + * The relative-compact branch matrix. + * + * Ported from the React SDK, which owned it before the runtime moved here — it was asserting this + * module's behaviour through a thin re-export. Four of these boundaries are regressions found while + * porting: a future timestamp rendering as "Today", `relativeCompactMaxWeeks: 0` rendering "0w ago", + * `relativeCompact` being ignored on the direct `getDateString` path, and the weeks branch firing + * before a full week had elapsed. + * + * `createDefaultTranslatorFunction` stands in for `t`: it honours the inline defaults and the + * `defaultValue_one` / `defaultValue_other` pair exactly as i18next would, which is the shape the + * formatter passes for the plural cases. + */ +describe('getDateString — relativeCompact', () => { + const FIXED_NOW = new Date('2025-02-19T12:00:00.000Z'); + const t = createDefaultTranslatorFunction(); + const tDateTimeParser = (input?: TDateTimeParserInput) => defaultDateTimeParser(input); + const daysBefore = (n: number) => + new Date(FIXED_NOW.getTime() - n * 24 * 60 * 60 * 1000).toISOString(); + + const render = (messageCreatedAt: string, options: Record = {}) => + getDateString({ + messageCreatedAt, + relativeCompact: true, + t, + tDateTimeParser, + ...options, + }); + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(FIXED_NOW); + }); + + it('renders today and yesterday as words', () => { + expect(render(FIXED_NOW.toISOString())).toBe('Today'); + expect(render(daysBefore(1))).toBe('Yesterday'); + }); + + it('renders 2–6 days as a day count', () => { + expect(render(daysBefore(2))).toBe('2d ago'); + expect(render(daysBefore(6))).toBe('6d ago'); + }); + + it('renders 1–3 weeks as a week count', () => { + expect(render(daysBefore(7))).toBe('1w ago'); + expect(render(daysBefore(21))).toBe('3w ago'); + }); + + it('falls back to a date beyond the week window', () => { + expect(render(daysBefore(28))).toBe('22/01/25'); + }); + + it('renders a future timestamp as a date, not as "Today"', () => { + const tomorrow = new Date(FIXED_NOW.getTime() + 24 * 60 * 60 * 1000).toISOString(); + expect(render(tomorrow)).toBe('20/02/25'); + }); + + it('never renders "0w ago" when relativeCompactMaxWeeks is 0', () => { + // `Math.floor(3 / 7) === 0`, which matched the weeks branch before the guard was added. + expect(render(daysBefore(3), { relativeCompactMaxWeeks: 0 })).toBe('3d ago'); + expect(render(daysBefore(9), { relativeCompactMaxWeeks: 0 })).toBe('10/02/25'); + }); + + it('honours relativeCompactMaxDays', () => { + expect(render(daysBefore(4), { relativeCompactMaxDays: 3 })).not.toBe('4d ago'); + expect(render(daysBefore(3), { relativeCompactMaxDays: 3 })).toBe('3d ago'); + }); + + it('is inert without both a translator and a parser', () => { + expect(render(daysBefore(1), { t: undefined })).not.toBe('Yesterday'); + expect(render(daysBefore(1), { tDateTimeParser: undefined })).not.toBe('Yesterday'); + }); +}); + +/** + * `calendarFormats` arriving as a string is not a quirk: a bundled default embeds the config inside the + * i18next expression, so the formatter receives text. Malformed text is a developer mistake, and the + * report goes to the instance logger rather than through `translate` — a diagnostic is not copy, and + * routing it through the translator was the original bug here. + */ +describe('timestampFormatter — malformed calendarFormats', () => { + const KEY_BAD = 'timestamp.MessageTimestamp'; + + it('reports invalid JSON through the instance logger and still renders', async () => { + const logger = vi.fn(); + const i18n = new StreamI18nForLogger({ + logger, + runtimeDefaults: { + // A bare non-JSON word. A brace-wrapped malformation never reaches the formatter at all -- + // i18next's own argument parser drops the whole argument first, so nothing is logged and the + // timestamp silently renders unformatted. Worth knowing: this guard only catches the subset + // i18next hands through. + [KEY_BAD]: + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: notjson) }}', + }, + }); + const { t } = await i18n.init(); + + const rendered = t(KEY_BAD, { timestamp: AT }); + + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('calendarFormats is not valid JSON'), + ); + // The malformed argument is dropped, not fatal — the calendar still renders, just with the + // locale's own formats. + expect(rendered).toBe('04/03/2019'); + }); + + it('accepts a well-formed JSON string', async () => { + const logger = vi.fn(); + const i18n = new StreamI18nForLogger({ + logger, + runtimeDefaults: { + [KEY_BAD]: + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {"sameElse":"YYYY"}) }}', + }, + }); + const { t } = await i18n.init(); + + expect(t(KEY_BAD, { timestamp: AT })).toBe('2019'); + expect(logger).not.toHaveBeenCalled(); + }); +}); + +/** + * Week-label boundaries, both roundings. + * + * `floor` is what this module has always done and what `stream-chat-react-native` shipped. `ceil` is + * what `stream-chat-react` shipped before its formatter moved here, and the difference is user-visible + * at 8, 15 and 22 days — which is why the mode is explicit rather than chosen. + */ +describe('relativeCompact — week rounding', () => { + const NOW = new Date('2026-04-30T12:00:00.000Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + const render = (daysAgo: number, weekRounding?: 'ceil' | 'floor') => + getDateString({ + messageCreatedAt: new Date(NOW.getTime() - daysAgo * 24 * 3600 * 1000), + relativeCompact: true, + relativeCompactWeekRounding: weekRounding, + t: createDefaultTranslatorFunction(), + tDateTimeParser: defaultDateTimeParser, + }); + + // maxDays 6 / maxWeeks 3 are the defaults for both. + it.each([ + [7, '1w ago', '1w ago'], + [8, '1w ago', '2w ago'], + [13, '1w ago', '2w ago'], + [14, '2w ago', '2w ago'], + [15, '2w ago', '3w ago'], + [21, '3w ago', '3w ago'], + ])('%i days ago — floor %s, ceil %s', (daysAgo, floorLabel, ceilLabel) => { + expect(render(daysAgo, 'floor')).toBe(floorLabel); + expect(render(daysAgo, 'ceil')).toBe(ceilLabel); + }); + + it('bounds the window on the week count under floor, and on days under ceil', () => { + // 22-27 days: three whole weeks elapsed, so `floor` still labels them... + expect(render(22, 'floor')).toBe('3w ago'); + expect(render(27, 'floor')).toBe('3w ago'); + // ...while `ceil` has already passed maxWeeks * 7 = 21 days and falls through to a date. + expect(render(22, 'ceil')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + expect(render(27, 'ceil')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + }); + + it('falls through to a date once both roundings are past the window', () => { + expect(render(28, 'floor')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + expect(render(28, 'ceil')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + }); + + it("defaults to floor, so an unset option keeps this module's long-standing behaviour", () => { + expect(render(8)).toBe('1w ago'); + expect(render(22)).toBe('3w ago'); + }); + + it('reads the option as text, the way an i18next expression supplies it', () => { + expect(render(8, 'ceil' as 'ceil')).toBe('2w ago'); + // Anything unrecognised degrades to the default rather than throwing inside the formatter. + expect(render(8, 'nonsense' as unknown as 'ceil')).toBe('1w ago'); + }); +}); diff --git a/test/unit/notifications/notificationTypes.test.ts b/test/unit/notifications/notificationTypes.test.ts new file mode 100644 index 0000000000..fa1d671eee --- /dev/null +++ b/test/unit/notifications/notificationTypes.test.ts @@ -0,0 +1,143 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + CORE_NOTIFICATION_TYPE, + isPollComposerValidationError, + POLL_COMPOSER_VALIDATION_CODE, + pollComposerValidationError, +} from '../../../src'; +import type { + CoreNotificationType, + PollComposerValidationCode, + PollComposerValidationError, +} from '../../../src'; + +const SRC = join(__dirname, '../../../src'); + +/** Generated OpenAPI models and the offline-support error taxonomy are out of scope. */ +const EXCLUDED_DIRS = ['gen', 'offline-support']; + +const sourceFiles = (dir: string): string[] => + readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + return EXCLUDED_DIRS.includes(entry) ? [] : sourceFiles(full); + } + return entry.endsWith('.ts') ? [full] : []; + }); + +const files = sourceFiles(SRC).map((path) => ({ + path: relative(SRC, path), + contents: readFileSync(path, 'utf8'), +})); + +const allSource = files.map((f) => f.contents).join('\n'); + +describe('CORE_NOTIFICATION_TYPE', () => { + it('is exported from the public barrel with its type', () => { + const value: CoreNotificationType = CORE_NOTIFICATION_TYPE.pollCreateFailed; + expect(value).toBe('api:poll:create:failed'); + }); + + it('follows the domain:entity:operation:result convention', () => { + for (const [key, type] of Object.entries(CORE_NOTIFICATION_TYPE)) { + expect(type, `${key} must have 3 or 4 colon-separated segments`).toMatch( + /^[a-z]+(:[a-zA-Z][\w-]*){2,3}$/, + ); + } + }); + + it('has no duplicate identifiers', () => { + const values = Object.values(CORE_NOTIFICATION_TYPE); + expect(new Set(values).size).toBe(values.length); + }); + + /** + * Guards against a dead identifier: one that UI SDKs still carry a translation for while nothing + * emits it any more. That is how both UI SDKs ended up with entries for types no SDK emits. + */ + it('emits every identifier it declares', () => { + const unused = Object.keys(CORE_NOTIFICATION_TYPE).filter( + (key) => !allSource.includes(`CORE_NOTIFICATION_TYPE.${key}`), + ); + expect(unused, 'declared but never emitted — remove it or emit it').toEqual([]); + }); + + /** + * Guards against the bypass: a raw string literal at a call site is invisible to the union, so it + * cannot be renamed safely and a typo never fails the build. + */ + it('is the only source of notification type literals in src/', () => { + const offenders = files.flatMap(({ path, contents }) => + contents + .split('\n') + .map((line, i) => ({ line, lineNumber: i + 1 })) + .filter(({ line }) => /\btype:\s*'[a-z]+:[a-zA-Z][\w-]*:/.test(line)) + .map(({ line, lineNumber }) => `${path}:${lineNumber} ${line.trim()}`), + ); + expect(offenders, 'use CORE_NOTIFICATION_TYPE. instead of a literal').toEqual( + [], + ); + }); +}); + +describe('POLL_COMPOSER_VALIDATION_CODE', () => { + it('is exported from the public barrel with its type and helpers', () => { + const code: PollComposerValidationCode = POLL_COMPOSER_VALIDATION_CODE.nameRequired; + const error: PollComposerValidationError = pollComposerValidationError(code); + expect(error).toEqual({ code, message: 'Question is required' }); + expect(isPollComposerValidationError(error)).toBe(true); + }); + + it('follows the same convention and has no duplicates', () => { + const values = Object.values(POLL_COMPOSER_VALIDATION_CODE); + expect(new Set(values).size).toBe(values.length); + for (const [key, code] of Object.entries(POLL_COMPOSER_VALIDATION_CODE)) { + expect(code, `${key} must be validation:poll::`).toMatch( + /^validation:poll:[a-zA-Z][\w-]*:[a-zA-Z][\w-]*$/, + ); + } + }); + + it('pairs every code with a non-empty English fallback', () => { + for (const code of Object.values(POLL_COMPOSER_VALIDATION_CODE)) { + expect( + pollComposerValidationError(code).message, + `${code} has no fallback`, + ).toBeTruthy(); + } + }); + + it('emits every code it declares', () => { + const unused = Object.keys(POLL_COMPOSER_VALIDATION_CODE).filter( + (key) => !allSource.includes(`POLL_COMPOSER_VALIDATION_CODE.${key}`), + ); + expect(unused, 'declared but never emitted').toEqual([]); + }); + + it('attaches metadata only when supplied', () => { + expect( + pollComposerValidationError(POLL_COMPOSER_VALIDATION_CODE.optionEmpty), + ).not.toHaveProperty('metadata'); + expect( + pollComposerValidationError(POLL_COMPOSER_VALIDATION_CODE.optionEmpty, { + optionId: 'a', + }).metadata, + ).toEqual({ optionId: 'a' }); + }); + + it('rejects non-errors in the narrowing guard', () => { + expect(isPollComposerValidationError(undefined)).toBe(false); + expect(isPollComposerValidationError('Option is empty')).toBe(false); + // an `options` error record, which is the other shape a field error can take + expect( + isPollComposerValidationError({ + 'option-1': pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, + ), + }), + ).toBe(false); + }); +}); diff --git a/tsconfig.codegen.json b/tsconfig.codegen.json new file mode 100644 index 0000000000..ec98725645 --- /dev/null +++ b/tsconfig.codegen.json @@ -0,0 +1,20 @@ +{ + // The i18n catalog generator, published as `stream-chat/i18n/codegen`. + // + // A separate project because the generator lives outside `src/`: it is Node-only build tooling that + // reads the filesystem, which is the one thing the SDK's own source must never do. Keeping it out of + // the library project is what makes that boundary type-enforced -- an accidental import from + // `src/i18n/` now fails at `tsc` rather than at the metafile assertion in `scripts/bundle.mts`. + // + // `rootDir` is `./codegen/i18n` rather than `./codegen` so the emitted declarations land at + // `dist/types/i18n-codegen/`, exactly where `package.json`'s `exports` and `typesVersions` point. The + // published layout is unchanged by the move. + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/types/i18n-codegen", + "rootDir": "./codegen/i18n", + // The generator runs under Node, not in a browser or a bundler. + "lib": ["ES2022"] + }, + "include": ["./codegen/**/*"] +} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000000..649748abbe --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,24 @@ +{ + // The repo's build and codegen scripts, typecheck-only. + // + // Node strips types from a `.mts` file; it does not check them. Without this project the annotations + // in `scripts/*.mts` could be wrong with no signal anywhere -- worse than the JSDoc `@type` comments + // they replaced, which at least were inert. `yarn types` runs it. + // + // `rootDir` is widened to the repo root because the base config pins it to `./src` for declaration + // emit; nothing is emitted here, so it only needs to contain the inputs. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "emitDeclarationOnly": false, + "declaration": false, + // These scripts run under Node directly, not through a bundler, so resolution has to match Node's + // own -- which is what makes `import ... with { type: 'json' }` and `.mjs` specifiers resolve. + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["./scripts/**/*.mts"] +} diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md new file mode 100644 index 0000000000..fb1fead654 --- /dev/null +++ b/v9-to-v10-migration-guide-i18n.md @@ -0,0 +1,323 @@ +# v9 → v10 Migration Guide — Notifications, Poll Validation & i18n + +> Scope: this guide covers **notification identity** (`Notification.type` and `Notification.message`), the **shape of poll-composer field errors**, and the new **`stream-chat/i18n`** and **`stream-chat/i18n/codegen`** subpath exports. It is relevant to you even if you never translate anything: the notification and poll-error changes affect any app that renders either. +> +> Sibling guides: +> +> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) +> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) +> - `v9-to-v10-migration-guide-methods.md` (per-method signatures) +> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal) +> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) +> - `v9-to-v10-migration-guide-type-renames.md` (type aliases → generated names) +> - `v9-to-v10-migration-guide-other.md` (everything else) + +## TL;DR + +- **Two notification identifiers were renamed.** `api:messages:query:failed` → `api:message:jump:failed`, and `api:message:query:failed` → `api:message:jumpToLatest:failed`. They were a singular/plural split describing two _different_ operations, which made the pair impossible to grep for reliably. **Breaking** if you switch on either. +- **`PollComposerFieldErrors` values are now objects**, not bare English strings: `{ code, message, metadata? }`. Read `.message` for the previous value, or switch on `.code` to localize. **Breaking.** +- **`Notification.type` is now typed** as `CoreNotificationType | (string & {})` and enumerated in the exported `CORE_NOTIFICATION_TYPE` map. Additive — your own identifiers still pass. +- **`Notification.message` is now documented as a developer-facing fallback, not display copy.** Its wording is not part of the public contract and may change in a minor release. Nothing breaks today, but anything user-facing should switch on `type`. See [Rendering notifications](#rendering-notifications). +- **New subpath `stream-chat/i18n`** carries the shared translation runtime (`Streami18n`, formatters, date handling). Nothing is re-exported from `stream-chat`'s root, so the root bundle is unchanged. +- **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only, and ESM-only — but `engines.node` is now `>=22.18.0`, and `require(esm)` has been unflagged since 22.12, so `require()` works on every supported Node as well as `import`. +- **`stream-chat` now depends on `i18next` and `dayjs`.** Install footprint grows ~2.3 MB; **bundle size is unaffected** unless you import `stream-chat/i18n`. +- Nothing in the JSDoc ever described a `Notification.code` field. There is no such field and never was — the block documenting the `domain:entity:operation:result` scheme was attached to `type` and mislabelled. It has been corrected. + +## Notification identity + +### `type` is the stable identifier; `message` is not + +Every notification `stream-chat` emits carries a `type`: a stable +`domain:entity:operation:result` identifier. That has been true since v10 rc, but it was typed as a bare +`string`, so nothing checked it and nothing enumerated it. + +v10 exports the full set, so you can switch on it with autocomplete and have a typo caught at compile +time: + +```ts +import { CORE_NOTIFICATION_TYPE } from 'stream-chat'; +import type { CoreNotificationType } from 'stream-chat'; + +client.notifications.state.subscribe(({ notifications }) => { + for (const notification of notifications) { + if (notification.type === CORE_NOTIFICATION_TYPE.attachmentUploadFailed) { + // … + } + } +}); +``` + +The field stays open (`CoreNotificationType | (string & {})`), so identifiers emitted by a UI SDK or by +your own code are still valid — you only lose autocomplete for them. + +### Every identifier core emits + +| Identifier | Suggested translation key | +| ------------------------------------------ | ----------------------------------------- | +| `validation:attachment:file:missing` | `notification.attachmentFileMissing` | +| `validation:attachment:id:missing` | `notification.attachmentIdMissing` | +| `validation:attachment:upload:blocked` | `notification.attachmentUploadBlocked` | +| `api:attachment:upload:failed` | `notification.attachmentUploadFailed` | +| `validation:attachment:upload:in-progress` | `notification.attachmentUploadInProgress` | +| `validation:command:disabled` | `notification.commandDisabled` | +| `validation:command:not-ready` | `notification.commandNotReady` | +| `api:location:create:failed` | `notification.locationCreateFailed` | +| `api:message:jump:failed` | `notification.messageJumpFailed` | +| `api:message:jumpToLatest:failed` | `notification.messageJumpToLatestFailed` | +| `validation:poll:castVote:limit` | `notification.pollCastVoteLimit` | +| `api:poll:create:failed` | `notification.pollCreateFailed` | + +The right-hand column is a suggestion, not an export. Each UI SDK uses its own key names — they predate +this table and integrators' dictionaries are already written against them — so there is no single +canonical set to publish. What _is_ exported, and what makes the mapping safe, is the +`CORE_NOTIFICATION_TYPE` union: keying a `Record` on it turns a new identifier +into a compile error until you map it, and rejects an entry for one that no longer exists. + +`validation:command:disabled` additionally carries `metadata.reason` (`'editing' | 'replying'`), which +its English message varies by. Copy for that key should interpolate `{{ reason }}`. + +### Renamed identifiers + +**Breaking.** Two identifiers described two different operations under near-identical names: + +| v9 / earlier v10 rc | v10 | What it means | +| --------------------------- | --------------------------------- | ------------------------------------ | +| `api:messages:query:failed` | `api:message:jump:failed` | jumping to a specific message failed | +| `api:message:query:failed` | `api:message:jumpToLatest:failed` | jumping to the latest message failed | + +The old pair differed only by a plural `s`, in the opposite order from what you would guess — the +_plural_ name was the single-message jump. Neither UI SDK had ever mapped either one, which is how the +mismatch survived. + +```ts +// v9 / earlier v10 rc +if (notification.type === 'api:messages:query:failed') showJumpError(); + +// v10 +if (notification.type === CORE_NOTIFICATION_TYPE.messageJumpFailed) showJumpError(); +``` + +### Rendering notifications + +`Notification.message` is untranslated English intended as a **developer-facing fallback**. Its exact +wording is not part of the public contract and can be reworded in a minor release. + +This is a contract change rather than an immediate break: the field still exists and still contains the +same text today. But if you render it directly, you are relying on something now documented as unstable, +and you have no way to localize it. + +```ts +// Before — the English sentence is the only thing identifying the notification +toast(notification.message); + +// After — dispatch on the identifier, and fall back to `message` for one you do not recognize +import { CORE_NOTIFICATION_TYPE } from 'stream-chat'; +import type { CoreNotificationType, Notification } from 'stream-chat'; + +const copy: Record string> = { + [CORE_NOTIFICATION_TYPE.attachmentUploadFailed]: () => t('notification.uploadFailed'), + // `validation:command:disabled` carries metadata.reason, so branch on it here + [CORE_NOTIFICATION_TYPE.commandDisabled]: (n) => + t('notification.commandDisabled', { reason: n.metadata?.reason }), + // …one entry per identifier; TypeScript will tell you which are missing +}; + +// `message` verbatim for anything unmapped, so a newer `stream-chat` cannot produce an empty toast. +toast( + notification.type && copy[notification.type as CoreNotificationType] + ? copy[notification.type as CoreNotificationType](notification) + : notification.message, +); +``` + +Type the record as `Record` rather than `Record` — that is the whole +point, and it is why core does not ship a ready-made resolver: your keys are yours, and a helper that +resolved them from a table would be invisible to a key-extraction step like the one both UI SDKs run. + +If you are using `stream-chat-react` or `stream-chat-react-native`, this is handled for you; see that +SDK's own i18n guide. + +## Poll-composer field errors + +**Breaking.** Field validation errors on the poll composer were bare English strings, which meant a UI +had to match on prose to localize them. They now carry a stable code: + +```ts +// v9 +type PollComposerFieldErrors = Partial< + Omit, 'options'> & { + options?: Record; + } +>; + +// v10 +type PollComposerValidationError = { + code: PollComposerValidationCode; + /** Untranslated English fallback. Not part of the public contract. */ + message: string; + metadata?: Record; +}; + +type PollComposerFieldErrors = Partial< + Omit, 'options'> & { + options?: Record; + } +>; +``` + +The one-property migration, if you do not want to localize: + +```ts +// v9 +{errors.name} +{errors.options?.[option.id]} + +// v10 +{errors.name?.message} +{errors.options?.[option.id]?.message} +``` + +To localize, switch on `code`: + +```ts +import { POLL_COMPOSER_VALIDATION_CODE } from 'stream-chat'; +import type { PollComposerValidationCode } from 'stream-chat'; + +const copy: Record = { + [POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric]: t('poll.maxVotes.notNumeric'), + // … +}; +const text = errors.name ? (copy[errors.name.code] ?? errors.name.message) : undefined; +``` + +`message` is kept alongside `code` deliberately: a plain-JS integrator gets a compile error with a +one-property fix rather than a silently blank field, and an unrecognized code still renders readable +text. + +### Every poll validation code + +| Code | English fallback | +| --------------------------------------------- | ------------------------------ | +| `validation:poll:maxVotes:notNumeric` | Only numbers are allowed | +| `validation:poll:maxVotes:outOfRange` | Type a number from 2 to 10 | +| `validation:poll:maxVotes:uniqueVoteEnforced` | Enforce unique vote is enabled | +| `validation:poll:name:required` | Question is required | +| `validation:poll:option:duplicate` | Option already exists | +| `validation:poll:option:empty` | Option is empty | + +These are **not** notifications and are deliberately not routed through `NotificationManager` — they are +field-level form state rendered inline next to an input, and a toast per keystroke would be wrong. + +## New subpath: `stream-chat/i18n` + +The translation runtime shared by the React and React Native SDKs now lives in core. If you use a UI +SDK, you do not need to import this directly — the SDK re-exports what you need, bound to its own key +catalog. + +```ts +import { Streami18n, getDateString, predefinedFormatters } from 'stream-chat/i18n'; +``` + +It is a separate entry point, not part of `stream-chat`'s root barrel, because it pulls in `i18next` and +`dayjs`. **The root bundle is unchanged** — the build fails if anything in `src/i18n/` becomes reachable +from it. + +Notable if you are building custom UI directly on `stream-chat`: + +- `Streami18n` is generic over your translation catalog: `new Streami18n(…)`. +- Reactivity goes through `i18n.state`, a `StateStore`. `subscribe` fires synchronously with the current + value, so there is no listener-registration ordering to get right. +- `setLanguage()` returns `Promise`. The new `t` is published to `state`; a returned translator + would go stale on the next language change. +- `init()` is idempotent and safe to call concurrently. +- The keys with no inline default at their call site are injected via the `runtimeDefaults` option, + because the catalog belongs to the UI layer rather than to core. + +### Removed from the `Streami18n` surface + +Both UI SDKs' v9 classes exposed these. They are gone rather than deprecated — v10 is a breaking +release, so an old name is removed rather than carried with a countdown on it. + +| Removed | Why, and what to do instead | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getTranslations()` | Returned the raw i18next resource map, which is internal bookkeeping rather than a catalog: prose keys are never bundled, so it never held the SDK's English copy. It had no consumer in either SDK. To check that a key resolves, render it: `i18n.t('some.key')`. | +| `getAvailableLanguages()` | Returned every language with a dictionary, **including** ones created only to carry the bundled defaults — so a language nobody registered appeared "available". Use `i18n.registeredLanguages`, which answers the question people were actually asking. | + +`registeredLanguages` is now a `ReadonlySet`. Reading it (`.has(code)`, spreading it) is +unchanged; `.add()` no longer compiles, because adding to it would claim a language is registered with no +dictionary behind it — exactly the state the unregistered-language warning exists to report. Call +`registerTranslation()` instead. + +These are now internal (`private`), having never been part of either SDK's documented API: +`translations`, `dayjsLocales`, `isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, +`validateCurrentLanguage()`. To register a dayjs locale directly, `stream-chat/i18n` exports +`addOrUpdateDayjsLocale()` and `dayjsLocaleExists()`. + +## New subpath: `stream-chat/i18n/codegen` + +Build-time only, **Node-only** and **ESM-only**: it reads the filesystem and uses the TypeScript parser +API. It generates a type-only translation-key catalog from your `t()` call sites, which is how a +mistyped key becomes a compile error. + +There is one artifact and no CommonJS build, since the caller is always a build script you control. From +an ESM script (`.mjs`, `.mts`, or a `"type": "module"` package) import it directly. A CommonJS script can +`require()` it too: `require(esm)` was unflagged in Node 22.12 and this package's floor is now 22.18.0. +`await import('stream-chat/i18n/codegen')` also works, on any Node. + +`typescript` is injected rather than imported, so `stream-chat` does not depend on the compiler: + +```ts +import ts from 'typescript'; +import { generateI18nKeys } from 'stream-chat/i18n/codegen'; + +generateI18nKeys({ + ts, + runtimeDefaultsPath: 'src/i18n/runtimeDefaults.ts', + keysOut: 'src/i18n/keys.ts', +}); +``` + +This is primarily for the UI SDKs. You only need it if you maintain your own translation catalog with +the same call-site-as-source-of-truth approach. + +## New dependencies + +`stream-chat` now depends on: + +| Package | Range | Why | +| --------- | ---------- | ------------------------------------------------- | +| `i18next` | `^26.3.6` | the translation runtime behind `stream-chat/i18n` | +| `dayjs` | `^1.11.13` | date and duration formatting | + +Direct dependencies rather than optional peers, so importing `stream-chat/i18n` works without you +installing anything extra. + +Two things to note: + +- **Bundle size is unaffected** if you do not import `stream-chat/i18n`. Both are externalized and the + root bundle is byte-identical. +- **Install footprint grows ~2.3 MB unpacked** (`i18next` ~416 KB, `dayjs` ~1.9 MB) even if you never + translate. This takes `stream-chat` from three runtime dependencies to five, which is a deliberate + trade: a package that imports something should depend on it rather than push the requirement onto + consumers. + +If you already declared `i18next` or `dayjs` because a UI SDK needed them, you can drop them — but check +that only one copy resolves, since two `i18next` instances mean dictionaries registered on one are read +from the other: + +```bash +find . -maxdepth 4 -name i18next -type d -path '*node_modules*' +``` + +## Mechanical migration checklist + +1. `grep -rn "api:messages:query:failed\|api:message:query:failed"` → replace with + `CORE_NOTIFICATION_TYPE.messageJumpFailed` / `.messageJumpToLatestFailed`. +2. `grep -rn "notification.message"` → for anything user-facing, switch on `notification.type` (use + `CORE_NOTIFICATION_TYPE`). Keep `message` + only as the unrecognized-identifier fallback. +3. Typecheck. Every `PollComposerFieldErrors` read will fail: append `?.message`, or switch on `.code`. +4. If you match notification identifiers anywhere, retype the local as `CoreNotificationType` to get the + set checked. +5. If you declared `i18next` or `dayjs` only for a Stream SDK, remove them and verify a single copy + resolves. diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 6e614d9443..a817c7dc05 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -1,16 +1,25 @@ # v9 → v10 Migration Guide — Everything Else -> Scope: this guide catches breaking changes **not** covered by the four topic-specific guides: +> Scope: this guide catches breaking changes **not** covered by the topic-specific guides: > > - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) > - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) > - `v9-to-v10-migration-guide-methods.md` (per-method signatures on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, `StableWSConnection`) > - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) +> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal, dropped Node-only deps) +> - `v9-to-v10-migration-guide-type-renames.md` (hand-rolled type aliases → generated names) +> - `v9-to-v10-migration-guide-i18n.md` (notification identity, poll-composer field errors, the `stream-chat/i18n` subpath) > > Read those first. This guide covers **exports, removed feature modules, event-type shape, filter constraints, small state/composer shape changes, and residual type/property renames** that the topic guides do not. ## TL;DR +- **`engines.node` is now `>=22.18.0`** (was `>=18`). Node 22.18 is the release that unflagged + TypeScript type stripping, which the package's own build scripts need — `prepare` runs the build, so a + git-ref install has to be able to execute them. A registry install never builds, so if you are pinned + to an older Node the runtime code itself is unlikely to care; `engines` is advisory and most package + managers warn rather than fail. But 18 and 20 are no longer tested. See the note below on what this + means if you deploy the WebSocket client on Node 18 or 20. - **Server-side is gone.** If you construct with a `secret` or call server-only admin endpoints, switch to `@stream-io/node-sdk`. The construction guide has the full list — every feature module below that was server-only is dropped for the same reason. - Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with exactly one export left, `UserFromToken`. The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. - `Event` (type name) is kept, but its shape widened: `Event = WSEvent | LocalEvent | keyof CustomEventTypes`. `EventPayload<''>` narrows to a specific event. @@ -23,6 +32,27 @@ --- +## Node version floor + +`engines.node` moves from `>=18` to `>=22.18.0`. + +The driver is the build, not the runtime: the package's build scripts are `.mts`, executed by `node` +with no loader, and unflagged type stripping landed in **22.18.0** (24.3.0 on the 24 line, 23.6.0 on +the 23 line). Because `prepare` runs `yarn build`, anyone installing from a git ref has to be able to +run them. Note that 22.12 — the `require(esm)` milestone — is _not_ sufficient for this; the two are +often conflated. + +**If you install from the npm registry, nothing in the shipped runtime is known to need 22.18.** You get +a prebuilt `dist/` and never run the build. `engines` is advisory, and npm/yarn warn rather than fail by +default. Treat the bump as "18 and 20 are no longer tested" rather than "the code will not run". + +**One place this needs care:** [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md) +documents running the WebSocket client on Node 18/20 by injecting a `WebSocketImpl` (Node only gained a +global `WebSocket` in 22). That guidance still works mechanically, and is still the right answer if you +are stuck on an older runtime — but it is now below the declared floor, so it is unsupported rather than +supported. If you are on Node 18 or 20 and rely on that path, plan the upgrade to 22.18+, where no +`WebSocketImpl` is needed at all. + ## Public export surface `src/index.ts` barrel changes: diff --git a/v9-to-v10-migration-guide-server-side.md b/v9-to-v10-migration-guide-server-side.md index 9aa240a660..d7b9642b51 100644 --- a/v9-to-v10-migration-guide-server-side.md +++ b/v9-to-v10-migration-guide-server-side.md @@ -562,6 +562,11 @@ The cast is because `ws` types its constructor with a slightly different `Messag One internal detail that matters if you inject `ws`: v9 called `ws.removeAllListeners()` during disconnect and teardown, an `EventEmitter` method the DOM `WebSocket` interface does not have. Those calls are gone. Teardown now relies on `close()` plus an internal `wsID` generation guard that makes callbacks from a superseded socket no-ops, so a `WebSocketImpl` only has to implement the four `on*` properties — it does **not** need `removeAllListeners`, `addEventListener`, or `off`. +> **`engines.node` is `>=22.18.0` as of v10**, so Node 18 and 20 are below the declared floor and no +> longer tested — see [the Node version floor](./v9-to-v10-migration-guide-other.md#node-version-floor). +> Everything in this section still works mechanically, and is still the right answer if you are stuck on +> an older runtime, but treat it as an unsupported bridge rather than a supported configuration. +> > **Officially, `WebSocketImpl` is documented as "purely for testing."** In practice it is also the escape hatch for Node <22 until the LTS ships a native `WebSocket`. If you rely on it in production, pin the `ws` version (it's stable, but its lifecycle isn't tied to `stream-chat`'s releases) and keep an eye on the SDK changelog in case the option gains stricter typing. ### Simplifying the hybrid example on Node 22+ diff --git a/vite.config.ts b/vite.config.ts index 6c85b43f84..88352c2381 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Date/time formatting assertions (src/i18n) are timezone-sensitive. Without this they pass only + // on a machine that happens to be in UTC, which is what CI is -- so a local run would disagree + // with CI by exactly the host's offset. + env: { TZ: 'UTC' }, testTimeout: 20000, // not all errors have been handled so this is necessary (at least for the time being) dangerouslyIgnoreUnhandledErrors: true, diff --git a/yarn.lock b/yarn.lock index 8dd5698e91..e426f22a1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2745,6 +2745,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.13": + version: 1.11.21 + resolution: "dayjs@npm:1.11.21" + checksum: 10c0/bd97dfdc4bfea3c66268635690313828b386faa040fbc1f829ff42a2bd748b72c9d9b3c8f9616ce9e61fcb78923f1461a462c969c54b1084458ae1b715898fb0 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -4180,6 +4187,18 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^26.3.6": + version: 26.3.6 + resolution: "i18next@npm:26.3.6" + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/5920ac8fb6b647a2bdd439d121de04e5297246e52c4d95d13f5faae0824fd45b2faeb66038bcd61fc62362daa815f9c32fb992d6faa3787607d1dfe768e9a8b1 + languageName: node + linkType: hard + "iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" @@ -7286,6 +7305,7 @@ __metadata: axios: "npm:^1.19.0" concurrently: "npm:^9.2.1" conventional-changelog-conventionalcommits: "npm:^9.3.1" + dayjs: "npm:^1.11.13" dotenv: "npm:^17.4.2" esbuild: "npm:^0.28.2" eslint: "npm:^9.39.4" @@ -7294,6 +7314,7 @@ __metadata: eslint-plugin-unused-imports: "npm:^4.4.1" globals: "npm:^17.6.0" husky: "npm:^9.1.7" + i18next: "npm:^26.3.6" linkifyjs: "npm:^4.3.3" lint-staged: "npm:^17.0.5" prettier: "npm:^3.8.3" @@ -7302,6 +7323,7 @@ __metadata: typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" vitest: "npm:^4.1.10" + yaml: "npm:^2.8.4" dependenciesMeta: esbuild: built: true