stream-chat 10.0.0-rc.4 → 10.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/codegen/i18n/callSites.ts +126 -0
- package/codegen/i18n/generate.ts +226 -0
- package/codegen/i18n/guards.ts +178 -0
- package/codegen/i18n/index.ts +29 -0
- package/codegen/i18n/stringMaps.ts +91 -0
- package/codegen/i18n/types.ts +100 -0
- package/dist/cjs/i18n.browser.js +1131 -0
- package/dist/cjs/i18n.browser.js.map +7 -0
- package/dist/cjs/i18n.node.js +1160 -0
- package/dist/cjs/i18n.node.js.map +7 -0
- package/dist/cjs/index.browser.js +580 -830
- package/dist/cjs/index.browser.js.map +4 -4
- package/dist/cjs/index.node.js +584 -843
- package/dist/cjs/index.node.js.map +4 -4
- package/dist/esm/i18n-codegen.mjs +409 -0
- package/dist/esm/i18n-codegen.mjs.map +7 -0
- package/dist/esm/i18n.mjs +1098 -0
- package/dist/esm/i18n.mjs.map +7 -0
- package/dist/esm/index.mjs +577 -827
- package/dist/esm/index.mjs.map +4 -4
- package/dist/types/LiveLocationManager.d.ts +5 -4
- package/dist/types/channel.d.ts +7 -80
- package/dist/types/client.d.ts +5 -108
- package/dist/types/gen/chat/ChannelApi.d.ts +17 -1
- package/dist/types/gen/chat/ChatApi.d.ts +19 -1
- package/dist/types/gen/models/index.d.ts +14 -4
- package/dist/types/i18n/Streami18n.d.ts +168 -0
- package/dist/types/i18n/TranslationBuilder.d.ts +70 -0
- package/dist/types/i18n/TranslationStore.d.ts +57 -0
- package/dist/types/i18n/dayjs.d.ts +104 -0
- package/dist/types/i18n/formatters.d.ts +91 -0
- package/dist/types/i18n/index.d.ts +15 -0
- package/dist/types/i18n/languageNames.d.ts +97 -0
- package/dist/types/i18n/translator.d.ts +29 -0
- package/dist/types/i18n/types.d.ts +262 -0
- package/dist/types/i18n-codegen/callSites.d.ts +10 -0
- package/dist/types/i18n-codegen/generate.d.ts +15 -0
- package/dist/types/i18n-codegen/guards.d.ts +61 -0
- package/dist/types/i18n-codegen/index.d.ts +29 -0
- package/dist/types/i18n-codegen/stringMaps.d.ts +14 -0
- package/dist/types/i18n-codegen/types.d.ts +94 -0
- package/dist/types/index.d.ts +0 -1
- package/dist/types/messageComposer/LocationComposer.d.ts +2 -2
- package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +2 -2
- package/dist/types/messageComposer/middleware/pollComposer/index.d.ts +1 -0
- package/dist/types/messageComposer/middleware/pollComposer/state.d.ts +3 -2
- package/dist/types/messageComposer/middleware/pollComposer/types.d.ts +9 -2
- package/dist/types/messageComposer/middleware/pollComposer/validation.d.ts +44 -0
- package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +7 -7
- package/dist/types/messageComposer/pollComposer.d.ts +1 -2
- package/dist/types/moderation.d.ts +14 -4
- package/dist/types/notifications/types.d.ts +51 -42
- package/dist/types/offline-support/types.d.ts +4 -4
- package/dist/types/pagination/cursorDerivation/linearPaginationFlags.d.ts +19 -2
- package/dist/types/pagination/paginators/ChannelPaginator.d.ts +8 -8
- package/dist/types/pagination/paginators/MessageIntervalPaginator.d.ts +3 -3
- package/dist/types/pagination/paginators/ReminderPaginator.d.ts +4 -4
- package/dist/types/poll.d.ts +9 -9
- package/dist/types/poll_manager.d.ts +2 -2
- package/dist/types/search/ChannelMemberSearchSource.d.ts +3 -3
- package/dist/types/search/ChannelSearchSource.d.ts +3 -3
- package/dist/types/search/MessageSearchSource.d.ts +5 -5
- package/dist/types/search/UserSearchSource.d.ts +3 -3
- package/dist/types/types.d.ts +62 -423
- package/dist/types/utils.d.ts +2 -2
- package/package.json +42 -8
- package/src/LiveLocationManager.ts +5 -6
- package/src/channel.ts +21 -159
- package/src/client.ts +7 -214
- package/src/gen/chat/ChannelApi.ts +33 -0
- package/src/gen/chat/ChatApi.ts +60 -0
- package/src/gen/model-decoders/decoders.ts +7 -0
- package/src/gen/models/index.ts +16 -4
- package/src/i18n/Streami18n.ts +520 -0
- package/src/i18n/TranslationBuilder.ts +176 -0
- package/src/i18n/TranslationStore.ts +96 -0
- package/src/i18n/dayjs.ts +228 -0
- package/src/i18n/formatters.ts +461 -0
- package/src/i18n/index.ts +15 -0
- package/src/i18n/languageNames.ts +103 -0
- package/src/i18n/translator.ts +76 -0
- package/src/i18n/types.ts +375 -0
- package/src/index.ts +0 -1
- package/src/messageComposer/LocationComposer.ts +2 -7
- package/src/messageComposer/attachmentManager.ts +5 -4
- package/src/messageComposer/messageComposer.ts +3 -2
- package/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts +2 -1
- package/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts +2 -1
- package/src/messageComposer/middleware/messageComposer/attachments.ts +2 -1
- package/src/messageComposer/middleware/messageComposer/types.ts +2 -7
- package/src/messageComposer/middleware/messageComposer/userDataInjection.ts +11 -6
- package/src/messageComposer/middleware/pollComposer/index.ts +1 -0
- package/src/messageComposer/middleware/pollComposer/state.ts +43 -11
- package/src/messageComposer/middleware/pollComposer/types.ts +9 -2
- package/src/messageComposer/middleware/pollComposer/validation.ts +76 -0
- package/src/messageComposer/middleware/textComposer/commandUtils.ts +3 -2
- package/src/messageComposer/middleware/textComposer/mentions.ts +7 -8
- package/src/messageComposer/pollComposer.ts +1 -2
- package/src/moderation.ts +15 -16
- package/src/notifications/types.ts +54 -42
- package/src/offline-support/offline_support_api.ts +15 -7
- package/src/offline-support/types.ts +5 -6
- package/src/pagination/cursorDerivation/linearPaginationFlags.ts +24 -6
- package/src/pagination/paginators/ChannelPaginator.ts +10 -10
- package/src/pagination/paginators/MessageIntervalPaginator.ts +12 -10
- package/src/pagination/paginators/MessagePaginator.ts +1 -1
- package/src/pagination/paginators/PinnedMessagePaginator.ts +4 -4
- package/src/pagination/paginators/ReminderPaginator.ts +5 -5
- package/src/pagination/utility.queryChannel.ts +1 -1
- package/src/poll.ts +17 -13
- package/src/poll_manager.ts +5 -5
- package/src/search/ChannelMemberSearchSource.ts +3 -3
- package/src/search/ChannelSearchSource.ts +3 -3
- package/src/search/MessageSearchSource.ts +5 -6
- package/src/search/UserSearchSource.ts +4 -4
- package/src/types.ts +73 -532
- package/src/utils.ts +7 -8
- package/dist/types/permissions.d.ts +0 -55
- package/src/permissions.ts +0 -102
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type * as ts from 'typescript';
|
|
4
|
+
|
|
5
|
+
import type { CallSiteCopy, TypeScriptModule } from './types';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_IGNORE_DIRS = ['__tests__', 'mock-builders'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every `t()` call in the source is the catalog's source of truth.
|
|
11
|
+
*
|
|
12
|
+
* A prose key exists because a component asks for it and passes its English copy inline; delete the
|
|
13
|
+
* call and the key is gone. That is what removes the need for a checked-in `en.json` and for an
|
|
14
|
+
* extract / remove-unused-keys pass, and it makes a dead prose key structurally impossible.
|
|
15
|
+
*
|
|
16
|
+
* The only keys that cannot be described this way are the ones with no inline copy — a formatter
|
|
17
|
+
* expression, or a key built from a runtime value. Those come from `runtimeDefaults`, and the generator
|
|
18
|
+
* cross-checks the two.
|
|
19
|
+
*/
|
|
20
|
+
const isTCallee = (tsModule: TypeScriptModule, expr: ts.Expression): boolean =>
|
|
21
|
+
(tsModule.isIdentifier(expr) && expr.text === 't') ||
|
|
22
|
+
(tsModule.isPropertyAccessExpression(expr) && expr.name.text === 't');
|
|
23
|
+
|
|
24
|
+
export const sourceFiles = ({
|
|
25
|
+
ignoreDirs = DEFAULT_IGNORE_DIRS,
|
|
26
|
+
srcRoot = 'src',
|
|
27
|
+
}: {
|
|
28
|
+
ignoreDirs?: string[];
|
|
29
|
+
srcRoot?: string;
|
|
30
|
+
} = {}): string[] => {
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
const walk = (dir: string) => {
|
|
33
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (ignoreDirs.includes(entry.name)) continue;
|
|
37
|
+
walk(full);
|
|
38
|
+
} else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
39
|
+
out.push(full);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
walk(srcRoot);
|
|
44
|
+
return out;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const readCallSiteCopy = ({
|
|
48
|
+
ignoreDirs,
|
|
49
|
+
srcRoot,
|
|
50
|
+
ts: tsModule,
|
|
51
|
+
}: {
|
|
52
|
+
ts: TypeScriptModule;
|
|
53
|
+
ignoreDirs?: string[];
|
|
54
|
+
srcRoot?: string;
|
|
55
|
+
}): CallSiteCopy => {
|
|
56
|
+
const copy = new Map<string, string>();
|
|
57
|
+
const withoutCopy = new Map<string, string>();
|
|
58
|
+
const pluralWithoutCopy = new Map<string, string>();
|
|
59
|
+
const conflicts: CallSiteCopy['conflicts'] = [];
|
|
60
|
+
|
|
61
|
+
const record = (key: string, value: string, file: string) => {
|
|
62
|
+
const existing = copy.get(key);
|
|
63
|
+
if (existing !== undefined && existing !== value) {
|
|
64
|
+
conflicts.push({ a: existing, b: value, file, key });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
copy.set(key, value);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (const file of sourceFiles({ ignoreDirs, srcRoot })) {
|
|
71
|
+
const sourceFile = tsModule.createSourceFile(
|
|
72
|
+
file,
|
|
73
|
+
fs.readFileSync(file, 'utf8'),
|
|
74
|
+
tsModule.ScriptTarget.Latest,
|
|
75
|
+
true,
|
|
76
|
+
file.endsWith('.tsx') ? tsModule.ScriptKind.TSX : tsModule.ScriptKind.TS,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const visit = (node: ts.Node) => {
|
|
80
|
+
if (tsModule.isCallExpression(node) && isTCallee(tsModule, node.expression)) {
|
|
81
|
+
const [keyArg, second] = node.arguments;
|
|
82
|
+
if (keyArg && tsModule.isStringLiteralLike(keyArg)) {
|
|
83
|
+
const key = keyArg.text;
|
|
84
|
+
if (second && tsModule.isStringLiteralLike(second)) {
|
|
85
|
+
// t('key', 'Copy')
|
|
86
|
+
record(key, second.text, file);
|
|
87
|
+
} else if (second && tsModule.isObjectLiteralExpression(second)) {
|
|
88
|
+
// t('key', { count, defaultValue_one, defaultValue_other }) — the catalog holds the
|
|
89
|
+
// `_one` / `_other` forms, never the bare key.
|
|
90
|
+
let plurals = 0;
|
|
91
|
+
let hasCount = false;
|
|
92
|
+
for (const prop of second.properties) {
|
|
93
|
+
// `count` can arrive shorthand (`{ count }`), which is not a PropertyAssignment.
|
|
94
|
+
if (
|
|
95
|
+
tsModule.isShorthandPropertyAssignment(prop) &&
|
|
96
|
+
prop.name.text === 'count'
|
|
97
|
+
) {
|
|
98
|
+
hasCount = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!tsModule.isPropertyAssignment(prop)) continue;
|
|
102
|
+
const name = prop.name.getText(sourceFile).replace(/['"]/g, '');
|
|
103
|
+
if (name === 'count') hasCount = true;
|
|
104
|
+
const suffix = name.match(/^defaultValue_(\w+)$/)?.[1];
|
|
105
|
+
if (suffix && tsModule.isStringLiteralLike(prop.initializer)) {
|
|
106
|
+
record(`${key}_${suffix}`, prop.initializer.text, file);
|
|
107
|
+
plurals++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// A `count` with no inline plural copy is a *bundled plural*: i18next will look up
|
|
111
|
+
// `<key>_<category>`, so the guard has to demand that shape rather than the bare key.
|
|
112
|
+
if (!plurals) (hasCount ? pluralWithoutCopy : withoutCopy).set(key, file);
|
|
113
|
+
} else {
|
|
114
|
+
// t('key') — no inline copy, so it has to resolve from runtimeDefaults.
|
|
115
|
+
withoutCopy.set(key, file);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
tsModule.forEachChild(node, visit);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
visit(sourceFile);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { conflicts, copy, pluralWithoutCopy, withoutCopy };
|
|
126
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readCallSiteCopy } from './callSites';
|
|
5
|
+
import {
|
|
6
|
+
formatFailures,
|
|
7
|
+
guardBundledPluralShape,
|
|
8
|
+
guardConflictingCopy,
|
|
9
|
+
guardPrefixCollisions,
|
|
10
|
+
guardShadowedKeys,
|
|
11
|
+
guardUnresolvableKeys,
|
|
12
|
+
} from './guards';
|
|
13
|
+
import { readStringMap } from './stringMaps';
|
|
14
|
+
import type { GeneratedCatalog, GeneratorConfig } from './types';
|
|
15
|
+
|
|
16
|
+
/** Values under these prefixes are dayjs/i18next expressions, not copy. */
|
|
17
|
+
const BUILTIN_FORMATTER_PREFIXES = ['timestamp.', 'duration.'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A catalog entry that is one plural category of a key, e.g. `x.y_other`.
|
|
21
|
+
*
|
|
22
|
+
* These are excluded from the emitted `BundledTranslationKey` union: a call site passes the *bare* key
|
|
23
|
+
* and `StreamTFunctionFor`'s plural overload already accepts it, so listing the suffixed forms as
|
|
24
|
+
* bundled keys would offer `t('x.y_other')` — a call that resolves nothing.
|
|
25
|
+
*/
|
|
26
|
+
const PLURAL_CATEGORY_SUFFIX = /_(zero|one|two|few|many|other)$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Two ways English hides inside a formatter expression, both of which have to be reported: a translator
|
|
30
|
+
* working from the JSON export never sees these keys otherwise.
|
|
31
|
+
*/
|
|
32
|
+
const hasEnglishWords = (value: string) =>
|
|
33
|
+
// Day words baked into a `calendarFormats` argument, e.g. `[Yesterday]` — dayjs escapes literal text
|
|
34
|
+
// in brackets.
|
|
35
|
+
[...value.matchAll(/\[([^\]]+)\]/g)].some(([, literal]) =>
|
|
36
|
+
/[A-Za-z]{2}/.test(literal),
|
|
37
|
+
) ||
|
|
38
|
+
// Prose sitting beside the interpolation, e.g. `Last seen {{ timestamp | … }}`. Each expression is
|
|
39
|
+
// matched individually — a greedy `{{[\s\S]*}}` would span from the first `{{` to the last `}}` and
|
|
40
|
+
// swallow the prose between two of them. Stopping at `}}` rather than any `}` is what keeps the
|
|
41
|
+
// nested braces of a `calendarFormats` argument inside the match.
|
|
42
|
+
/[A-Za-z]{2}/.test(value.replace(/\{\{(?:[^}]|\}(?!\}))*\}\}/g, ''));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Builds the catalog and runs every guard, without writing anything.
|
|
46
|
+
*
|
|
47
|
+
* Separate from {@link generateI18nKeys} so a test — or a caller wanting to inspect before committing —
|
|
48
|
+
* can get the failures as data rather than as process output.
|
|
49
|
+
*/
|
|
50
|
+
export const buildCatalog = (config: GeneratorConfig): GeneratedCatalog => {
|
|
51
|
+
const { runtimeDefaultsPath, ts } = config;
|
|
52
|
+
|
|
53
|
+
const runtimeDefaults = readStringMap({
|
|
54
|
+
exportName: 'runtimeDefaults',
|
|
55
|
+
file: runtimeDefaultsPath,
|
|
56
|
+
ts,
|
|
57
|
+
});
|
|
58
|
+
const {
|
|
59
|
+
conflicts,
|
|
60
|
+
copy: inlineCopy,
|
|
61
|
+
pluralWithoutCopy,
|
|
62
|
+
withoutCopy,
|
|
63
|
+
} = readCallSiteCopy({
|
|
64
|
+
ignoreDirs: config.ignoreDirs,
|
|
65
|
+
srcRoot: config.srcRoot,
|
|
66
|
+
ts,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const catalogEntries = new Map([...inlineCopy, ...runtimeDefaults]);
|
|
70
|
+
const keys = [...catalogEntries.keys()].sort();
|
|
71
|
+
const catalog = new Map(keys.map((key) => [key, catalogEntries.get(key) as string]));
|
|
72
|
+
|
|
73
|
+
const failures = [
|
|
74
|
+
guardConflictingCopy(conflicts),
|
|
75
|
+
guardUnresolvableKeys({
|
|
76
|
+
pluralWithoutCopy,
|
|
77
|
+
runtimeDefaults,
|
|
78
|
+
runtimeDefaultsPath,
|
|
79
|
+
withoutCopy,
|
|
80
|
+
}),
|
|
81
|
+
guardBundledPluralShape({ pluralWithoutCopy, runtimeDefaults, runtimeDefaultsPath }),
|
|
82
|
+
guardShadowedKeys({ inlineCopy, runtimeDefaults, runtimeDefaultsPath }),
|
|
83
|
+
guardPrefixCollisions(keys),
|
|
84
|
+
].filter((failure): failure is NonNullable<typeof failure> => failure !== null);
|
|
85
|
+
|
|
86
|
+
return { bundledKeys: [...runtimeDefaults.keys()].sort(), catalog, failures };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const renderKeysFile = ({
|
|
90
|
+
bundledKeys,
|
|
91
|
+
catalog,
|
|
92
|
+
emitBundledKeyUnion,
|
|
93
|
+
}: {
|
|
94
|
+
bundledKeys: string[];
|
|
95
|
+
catalog: Map<string, string>;
|
|
96
|
+
emitBundledKeyUnion?: boolean;
|
|
97
|
+
}): string => {
|
|
98
|
+
const lines: string[] = [
|
|
99
|
+
'// AUTO-GENERATED — do not edit by hand.',
|
|
100
|
+
'// Regenerate with `yarn build-translations`. CI fails if this file is out of sync.',
|
|
101
|
+
'//',
|
|
102
|
+
'// Type-only: no runtime value is emitted, so this adds nothing to the bundle.',
|
|
103
|
+
'',
|
|
104
|
+
'/**',
|
|
105
|
+
' * Every translation entry shipped with the SDK, mapped to its English copy.',
|
|
106
|
+
' *',
|
|
107
|
+
' * Plural entries appear as `<key>_one` / `<key>_other`; call sites use the bare `<key>` and',
|
|
108
|
+
' * pass `count`.',
|
|
109
|
+
' */',
|
|
110
|
+
'export type TranslationCatalog = {',
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
for (const [key, value] of catalog) {
|
|
114
|
+
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)};`);
|
|
115
|
+
}
|
|
116
|
+
lines.push('};', '');
|
|
117
|
+
|
|
118
|
+
if (emitBundledKeyUnion) {
|
|
119
|
+
lines.push(
|
|
120
|
+
'/**',
|
|
121
|
+
' * Keys whose copy is bundled rather than passed inline at the call site.',
|
|
122
|
+
' *',
|
|
123
|
+
' * They reach `t()` as runtime values — a JSX prop, a ternary branch, a lookup table — so there',
|
|
124
|
+
' * is nowhere to write a `defaultValue`. Call sites pass the key alone.',
|
|
125
|
+
' */',
|
|
126
|
+
'export type BundledTranslationKey =',
|
|
127
|
+
);
|
|
128
|
+
for (const key of bundledKeys) lines.push(` | ${JSON.stringify(key)}`);
|
|
129
|
+
lines.push(';', '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Regenerates an SDK's translation catalog from its `t()` call sites and bundled defaults.
|
|
137
|
+
*
|
|
138
|
+
* Throws on a guard failure with every failure formatted, so the caller's script exits non-zero and CI
|
|
139
|
+
* fails. The catalog is written only when all guards pass.
|
|
140
|
+
*/
|
|
141
|
+
export const generateI18nKeys = (config: GeneratorConfig): GeneratedCatalog => {
|
|
142
|
+
const log = config.log ?? ((message: string) => console.log(message));
|
|
143
|
+
const result = buildCatalog(config);
|
|
144
|
+
|
|
145
|
+
if (result.failures.length) {
|
|
146
|
+
throw new Error(formatFailures(result.failures));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { bundledKeys, catalog } = result;
|
|
150
|
+
const keys = [...catalog.keys()];
|
|
151
|
+
|
|
152
|
+
fs.mkdirSync(path.dirname(config.keysOut), { recursive: true });
|
|
153
|
+
fs.writeFileSync(
|
|
154
|
+
config.keysOut,
|
|
155
|
+
renderKeysFile({
|
|
156
|
+
bundledKeys: bundledKeys.filter((key) => !PLURAL_CATEGORY_SUFFIX.test(key)),
|
|
157
|
+
catalog,
|
|
158
|
+
emitBundledKeyUnion: config.emitBundledKeyUnion,
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (config.fixtureOut) {
|
|
163
|
+
fs.mkdirSync(path.dirname(config.fixtureOut), { recursive: true });
|
|
164
|
+
fs.writeFileSync(
|
|
165
|
+
config.fixtureOut,
|
|
166
|
+
`${JSON.stringify(Object.fromEntries(catalog), null, 2)}\n`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
log(
|
|
171
|
+
`generated ${config.keysOut} (${keys.length} entries, type-only) — ` +
|
|
172
|
+
`${keys.length - bundledKeys.length} from inline defaults, ${bundledKeys.length} bundled`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (config.json) {
|
|
176
|
+
const formatterPrefixes = [
|
|
177
|
+
...BUILTIN_FORMATTER_PREFIXES,
|
|
178
|
+
...(config.extraFormatterPrefixes ?? []),
|
|
179
|
+
];
|
|
180
|
+
const isFormatterKey = (key: string) =>
|
|
181
|
+
formatterPrefixes.some((prefix) => key.startsWith(prefix));
|
|
182
|
+
|
|
183
|
+
const exported = config.json.includeFormats
|
|
184
|
+
? keys
|
|
185
|
+
: keys.filter((k) => !isFormatterKey(k));
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
config.json.out,
|
|
188
|
+
`${JSON.stringify(
|
|
189
|
+
Object.fromEntries(exported.map((key) => [key, catalog.get(key)])),
|
|
190
|
+
null,
|
|
191
|
+
2,
|
|
192
|
+
)}\n`,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
log(
|
|
196
|
+
`wrote ${config.json.out} (${exported.length} ${
|
|
197
|
+
config.json.includeFormats
|
|
198
|
+
? 'entries, formatter expressions included'
|
|
199
|
+
: 'translatable entries'
|
|
200
|
+
})`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const excluded = keys.filter((key) => !exported.includes(key));
|
|
204
|
+
if (excluded.length) {
|
|
205
|
+
// Excluding formatter expressions does drop some translatable text: a few embed English day
|
|
206
|
+
// words. It is not translatable *as copy* — the format string has to be rewritten — so it is
|
|
207
|
+
// named here and handled by overriding the key. Detected rather than hardcoded, so the list
|
|
208
|
+
// cannot go stale.
|
|
209
|
+
const withEnglish = excluded.filter((key) =>
|
|
210
|
+
hasEnglishWords(catalog.get(key) as string),
|
|
211
|
+
);
|
|
212
|
+
log(
|
|
213
|
+
` excluded ${excluded.length} formatter expressions (${formatterPrefixes.join(', ')}) — ` +
|
|
214
|
+
`not copy, and a TMS that translates them breaks date rendering. Pass --all to include ` +
|
|
215
|
+
`them.` +
|
|
216
|
+
(withEnglish.length
|
|
217
|
+
? `\n ${withEnglish.length} of them do carry English copy and must be translated by ` +
|
|
218
|
+
`overriding the key:\n${withEnglish.map((key) => ` ${key}`).join('\n')}` +
|
|
219
|
+
(config.migrationGuideRef ? `\n see ${config.migrationGuideRef}.` : '')
|
|
220
|
+
: ''),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return result;
|
|
226
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { CallSiteCopy, GuardFailure } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The five hard-fail checks the catalog has to pass.
|
|
5
|
+
*
|
|
6
|
+
* Each is a pure function returning failures as data. The guard both UI SDKs carried that is *not* here
|
|
7
|
+
* — checking that an `EXTERNAL_STRING_KEYS` entry's wording matched the key's catalog copy — is gone
|
|
8
|
+
* because the map it policed is gone: notifications now resolve through a stable identifier instead of
|
|
9
|
+
* by matching English prose.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A key must render one thing. */
|
|
13
|
+
export const guardConflictingCopy = (
|
|
14
|
+
conflicts: CallSiteCopy['conflicts'],
|
|
15
|
+
): GuardFailure | null => {
|
|
16
|
+
if (!conflicts.length) return null;
|
|
17
|
+
return {
|
|
18
|
+
entries: conflicts.map(
|
|
19
|
+
({ a, b, file, key }) =>
|
|
20
|
+
`${key}\n ${JSON.stringify(a)}\n ${JSON.stringify(b)} (${file})`,
|
|
21
|
+
),
|
|
22
|
+
kind: 'conflicting-copy',
|
|
23
|
+
summary:
|
|
24
|
+
`${conflicts.length} key(s) used with conflicting inline copy — a key must render ` +
|
|
25
|
+
`one thing:`,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A key called without inline copy resolves from the bundled data or not at all.
|
|
31
|
+
*
|
|
32
|
+
* Without this, i18next renders the raw dotted key in the UI — the failure mode is a user seeing
|
|
33
|
+
* `message.status.sent.text` where a word should be.
|
|
34
|
+
*/
|
|
35
|
+
export const guardUnresolvableKeys = ({
|
|
36
|
+
pluralWithoutCopy,
|
|
37
|
+
runtimeDefaults,
|
|
38
|
+
runtimeDefaultsPath,
|
|
39
|
+
withoutCopy,
|
|
40
|
+
}: {
|
|
41
|
+
pluralWithoutCopy: CallSiteCopy['pluralWithoutCopy'];
|
|
42
|
+
runtimeDefaults: Map<string, string>;
|
|
43
|
+
runtimeDefaultsPath: string;
|
|
44
|
+
withoutCopy: CallSiteCopy['withoutCopy'];
|
|
45
|
+
}): GuardFailure | null => {
|
|
46
|
+
const unresolvable = [
|
|
47
|
+
...[...withoutCopy].filter(([key]) => !runtimeDefaults.has(key)),
|
|
48
|
+
// A plural resolves as `<key>_<category>`, so `_other` — the one category every language has — is
|
|
49
|
+
// what has to be bundled. Demanding the bare key here is what used to reject a correct catalog.
|
|
50
|
+
//
|
|
51
|
+
// A key bundled under the *bare* name is skipped here on purpose: it is not unresolvable, it is
|
|
52
|
+
// the wrong shape, and `guardBundledPluralShape` says so precisely. Reporting both would give two
|
|
53
|
+
// failures with different advice for one mistake.
|
|
54
|
+
...[...pluralWithoutCopy]
|
|
55
|
+
.filter(
|
|
56
|
+
([key]) => !runtimeDefaults.has(`${key}_other`) && !runtimeDefaults.has(key),
|
|
57
|
+
)
|
|
58
|
+
.map(([key, file]): [string, string] => [`${key}_other`, file]),
|
|
59
|
+
];
|
|
60
|
+
if (!unresolvable.length) return null;
|
|
61
|
+
return {
|
|
62
|
+
entries: unresolvable.map(([key, file]) => `${key} (${file})`),
|
|
63
|
+
kind: 'unresolvable-key',
|
|
64
|
+
summary:
|
|
65
|
+
`${unresolvable.length} key(s) are called with no inline default and are missing from ` +
|
|
66
|
+
`${runtimeDefaultsPath}.\nThey would render as the raw key. Either pass the English copy ` +
|
|
67
|
+
`inline — t('key', 'Copy') — or add an entry to ${runtimeDefaultsPath}:`,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A bundled plural must be stored under its category suffixes, not under the bare key.
|
|
73
|
+
*
|
|
74
|
+
* i18next falls back to an unsuffixed entry when no `<key>_<category>` exists, so the bare form does
|
|
75
|
+
* render — it just renders the same string for every count, with plural selection silently dead and no
|
|
76
|
+
* error anywhere. Verified against i18next directly: a bundled `'x.y': '{{count}} items'` answers
|
|
77
|
+
* `count: 1` with "1 items".
|
|
78
|
+
*/
|
|
79
|
+
export const guardBundledPluralShape = ({
|
|
80
|
+
pluralWithoutCopy,
|
|
81
|
+
runtimeDefaults,
|
|
82
|
+
runtimeDefaultsPath,
|
|
83
|
+
}: {
|
|
84
|
+
pluralWithoutCopy: CallSiteCopy['pluralWithoutCopy'];
|
|
85
|
+
runtimeDefaults: Map<string, string>;
|
|
86
|
+
runtimeDefaultsPath: string;
|
|
87
|
+
}): GuardFailure | null => {
|
|
88
|
+
const bare = [...pluralWithoutCopy].filter(([key]) => runtimeDefaults.has(key));
|
|
89
|
+
if (!bare.length) return null;
|
|
90
|
+
return {
|
|
91
|
+
entries: bare.map(
|
|
92
|
+
([key, file]) =>
|
|
93
|
+
`${key}\n bundled as: ${JSON.stringify(runtimeDefaults.get(key))}\n` +
|
|
94
|
+
` called as: t('${key}', { count }) (${file})`,
|
|
95
|
+
),
|
|
96
|
+
kind: 'bundled-plural-shape',
|
|
97
|
+
summary:
|
|
98
|
+
`${bare.length} key(s) are called with \`count\` but bundled under the bare key in ` +
|
|
99
|
+
`${runtimeDefaultsPath}.\ni18next resolves plurals as \`<key>_<category>\`, so this renders ` +
|
|
100
|
+
`one form for every count with no error. Split the entry into \`_one\` / \`_other\`:`,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A key must not be in both places.
|
|
106
|
+
*
|
|
107
|
+
* The bundled value wins over a `defaultValue`, so a key in both silently renders the bundled string
|
|
108
|
+
* and ignores the call site — meaning an edit to the copy at the call site changes nothing, with no
|
|
109
|
+
* error. This is the bug class that used to hide behind a checked-in `en.json`.
|
|
110
|
+
*/
|
|
111
|
+
export const guardShadowedKeys = ({
|
|
112
|
+
inlineCopy,
|
|
113
|
+
runtimeDefaults,
|
|
114
|
+
runtimeDefaultsPath,
|
|
115
|
+
}: {
|
|
116
|
+
inlineCopy: Map<string, string>;
|
|
117
|
+
runtimeDefaults: Map<string, string>;
|
|
118
|
+
runtimeDefaultsPath: string;
|
|
119
|
+
}): GuardFailure | null => {
|
|
120
|
+
const shadowed = [...runtimeDefaults.keys()].filter((key) => inlineCopy.has(key));
|
|
121
|
+
if (!shadowed.length) return null;
|
|
122
|
+
return {
|
|
123
|
+
entries: shadowed.map(
|
|
124
|
+
(key) =>
|
|
125
|
+
`${key}\n bundled: ${JSON.stringify(runtimeDefaults.get(key))}\n` +
|
|
126
|
+
` call site: ${JSON.stringify(inlineCopy.get(key))}`,
|
|
127
|
+
),
|
|
128
|
+
kind: 'shadowed-key',
|
|
129
|
+
summary:
|
|
130
|
+
`${shadowed.length} key(s) are in both ${runtimeDefaultsPath} and an inline default.\n` +
|
|
131
|
+
`The bundled value wins, so editing the call site would silently change nothing. Remove ` +
|
|
132
|
+
`the ${runtimeDefaultsPath} entry:`,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A key cannot be both a leaf and a namespace.
|
|
138
|
+
*
|
|
139
|
+
* With i18next's default `keySeparator: '.'` the shorter key would resolve to an object, and a nested
|
|
140
|
+
* resource tree cannot represent both at once. The SDKs set `keySeparator: false` so this is latent
|
|
141
|
+
* rather than active — but it is a landmine for anyone who ever flips that, and cheap to prevent.
|
|
142
|
+
*
|
|
143
|
+
* Compared on segment boundaries, so `poll.title` / `poll.titleText` is fine while `poll.title` /
|
|
144
|
+
* `poll.title.text` is not.
|
|
145
|
+
*/
|
|
146
|
+
export const guardPrefixCollisions = (keys: string[]): GuardFailure | null => {
|
|
147
|
+
const keySet = new Set(keys);
|
|
148
|
+
const collisions: Array<{ leaf: string; nested: string }> = [];
|
|
149
|
+
|
|
150
|
+
for (const key of keys) {
|
|
151
|
+
const segments = key.split('.');
|
|
152
|
+
for (let i = 1; i < segments.length; i++) {
|
|
153
|
+
const ancestor = segments.slice(0, i).join('.');
|
|
154
|
+
if (keySet.has(ancestor)) collisions.push({ leaf: ancestor, nested: key });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!collisions.length) return null;
|
|
159
|
+
return {
|
|
160
|
+
entries: collisions.map(
|
|
161
|
+
({ leaf, nested }) => `${leaf}\n is a strict prefix of: ${nested}`,
|
|
162
|
+
),
|
|
163
|
+
kind: 'prefix-collision',
|
|
164
|
+
summary:
|
|
165
|
+
`${collisions.length} key(s) are a strict prefix of another key — a key cannot be both a ` +
|
|
166
|
+
`leaf and a namespace. Rename one, usually by giving the shorter key a modality segment ` +
|
|
167
|
+
`(.label / .text / .title):`,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Formats failures the way the generator prints them before exiting. */
|
|
172
|
+
export const formatFailures = (failures: GuardFailure[]): string =>
|
|
173
|
+
failures
|
|
174
|
+
.map(
|
|
175
|
+
({ entries, summary }) =>
|
|
176
|
+
`\n${summary}\n${entries.map((e) => ` ${e}`).join('\n')}`,
|
|
177
|
+
)
|
|
178
|
+
.join('\n');
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time codegen for a UI SDK's translation catalog, published as `stream-chat/i18n/codegen`.
|
|
3
|
+
*
|
|
4
|
+
* **Node-only.** This reads the filesystem and uses the TypeScript parser API, so it must never be
|
|
5
|
+
* reachable from `stream-chat/i18n` — which is why it lives beside `src/i18n/` rather than inside it.
|
|
6
|
+
* `scripts/bundle.mts` asserts that boundary at build time.
|
|
7
|
+
*
|
|
8
|
+
* `typescript` is injected through {@link GeneratorConfig.ts} rather than imported, so `stream-chat`
|
|
9
|
+
* does not depend on the compiler.
|
|
10
|
+
*
|
|
11
|
+
* Each SDK keeps a thin script that supplies its own paths:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import ts from 'typescript';
|
|
15
|
+
* import { generateI18nKeys } from 'stream-chat/i18n/codegen';
|
|
16
|
+
*
|
|
17
|
+
* generateI18nKeys({
|
|
18
|
+
* ts,
|
|
19
|
+
* runtimeDefaultsPath: 'src/i18n/runtimeDefaults.ts',
|
|
20
|
+
* keysOut: 'src/i18n/keys.ts',
|
|
21
|
+
* fixtureOut: 'src/i18n/__tests__/catalog.fixture.json',
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export * from './callSites';
|
|
26
|
+
export * from './generate';
|
|
27
|
+
export * from './guards';
|
|
28
|
+
export * from './stringMaps';
|
|
29
|
+
export * from './types';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import type * as ts from 'typescript';
|
|
3
|
+
|
|
4
|
+
import type { TypeScriptModule } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads a flat `Record<string, string>` export out of a source file.
|
|
8
|
+
*
|
|
9
|
+
* Parsed rather than imported: `await import()` works under Node's type stripping but warns
|
|
10
|
+
* `MODULE_TYPELESS_PACKAGE_JSON` on every run, and the SDK packages cannot be `"type": "module"`.
|
|
11
|
+
*
|
|
12
|
+
* Throws rather than exiting, so a caller — including a test — can handle the failure.
|
|
13
|
+
*/
|
|
14
|
+
export const readStringMap = ({
|
|
15
|
+
exportName,
|
|
16
|
+
file,
|
|
17
|
+
ts: tsModule,
|
|
18
|
+
}: {
|
|
19
|
+
exportName: string;
|
|
20
|
+
file: string;
|
|
21
|
+
ts: TypeScriptModule;
|
|
22
|
+
}): Map<string, string> => {
|
|
23
|
+
if (!fs.existsSync(file)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`i18n-codegen: could not read the file expected to export \`${exportName}\`: ${file}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const source = tsModule.createSourceFile(
|
|
30
|
+
file,
|
|
31
|
+
fs.readFileSync(file, 'utf8'),
|
|
32
|
+
tsModule.ScriptTarget.Latest,
|
|
33
|
+
true,
|
|
34
|
+
tsModule.ScriptKind.TS,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const out = new Map<string, string>();
|
|
38
|
+
let found = false;
|
|
39
|
+
|
|
40
|
+
tsModule.forEachChild(source, (node) => {
|
|
41
|
+
if (!tsModule.isVariableStatement(node)) return;
|
|
42
|
+
|
|
43
|
+
for (const declaration of node.declarationList.declarations) {
|
|
44
|
+
if (
|
|
45
|
+
!tsModule.isIdentifier(declaration.name) ||
|
|
46
|
+
declaration.name.text !== exportName ||
|
|
47
|
+
!declaration.initializer
|
|
48
|
+
) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `= { … } as const` and `satisfies …` are both fine.
|
|
53
|
+
let initializer: ts.Expression = declaration.initializer;
|
|
54
|
+
while (
|
|
55
|
+
tsModule.isAsExpression(initializer) ||
|
|
56
|
+
tsModule.isSatisfiesExpression(initializer)
|
|
57
|
+
) {
|
|
58
|
+
initializer = initializer.expression;
|
|
59
|
+
}
|
|
60
|
+
if (!tsModule.isObjectLiteralExpression(initializer)) continue;
|
|
61
|
+
|
|
62
|
+
found = true;
|
|
63
|
+
for (const property of initializer.properties) {
|
|
64
|
+
if (!tsModule.isPropertyAssignment(property)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`i18n-codegen: ${exportName} in ${file} must be a flat object of string literals, got: ` +
|
|
67
|
+
property.getText(source).slice(0, 80),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
!tsModule.isStringLiteralLike(property.name) ||
|
|
72
|
+
!tsModule.isStringLiteralLike(property.initializer)
|
|
73
|
+
) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`i18n-codegen: ${exportName} entries must be 'quoted.key': 'string literal', got: ` +
|
|
76
|
+
property.getText(source).slice(0, 80),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
out.set(property.name.text, property.initializer.text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!found) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`i18n-codegen: could not find an exported \`${exportName}\` object literal in ${file}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return out;
|
|
91
|
+
};
|