Skip to content

Commit ea1bc8c

Browse files
committed
feat(hooks): expose extraction lifecycle hooks
1 parent ba49f4a commit ea1bc8c

7 files changed

Lines changed: 419 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 0.2.0
4+
5+
### Features
6+
7+
- Add public compilation hooks:
8+
- `afterExtract` for consuming extracted translations after key extraction
9+
- `renderExtractedTranslations` for customizing or skipping JS asset injection
10+
- Export `getI18nextExtractorWebpackPluginHooks`, `AfterExtractPayload`, and `RenderExtractedTranslationsPayload` for plugin consumers
11+
312
## 0.1.4
413

514
### Features

README.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,148 @@ import { i18n } from './i18n';
8888
console.log(i18n.t('hello'));
8989
```
9090

91+
## Hooks
92+
93+
`rsbuild-plugin-i18next-extractor` now exposes compilation hooks so other build-time plugins can consume extracted translations or customize how the extracted payload is written back to JS assets.
94+
95+
Exported APIs:
96+
97+
- `getI18nextExtractorWebpackPluginHooks(compilation)`
98+
- `AfterExtractPayload`
99+
- `RenderExtractedTranslationsPayload`
100+
101+
### `afterExtract`
102+
103+
Called after translation keys have been extracted and locale payloads have been assembled for an entry.
104+
105+
This hook is useful when you want to:
106+
107+
- store extracted translations for a later build step
108+
- inspect extracted keys for debugging or reporting
109+
- feed extracted translations into another plugin
110+
111+
Payload shape:
112+
113+
```ts
114+
type AfterExtractPayload = {
115+
entryName: string;
116+
locales: string[];
117+
files: string[];
118+
extractedKeysByLocale: Record<string, string[]>;
119+
extractedTranslationsByLocale: Record<string, Record<string, string>>;
120+
};
121+
```
122+
123+
### `renderExtractedTranslations`
124+
125+
Called before extracted translations are prepended back into JS assets.
126+
127+
The default behavior is still:
128+
129+
```ts
130+
const __I18N_EN_EXTRACTED_TRANSLATIONS__ = { ... };
131+
```
132+
133+
This hook is useful when you want to:
134+
135+
- customize the injected JS code
136+
- skip JS injection for some or all locales
137+
- redirect the extracted payload to another artifact pipeline
138+
139+
Payload shape:
140+
141+
```ts
142+
type RenderExtractedTranslationsPayload = {
143+
entryName: string;
144+
locale: string;
145+
variableName: string;
146+
extractedKeys: string[];
147+
extractedTranslations: Record<string, string>;
148+
targetAssetNames: string[];
149+
code: string;
150+
skip?: boolean;
151+
};
152+
```
153+
154+
### Hook Example
155+
156+
```ts
157+
import {
158+
defineConfig,
159+
type RsbuildPlugin,
160+
type Rspack,
161+
} from '@rsbuild/core';
162+
import {
163+
getI18nextExtractorWebpackPluginHooks,
164+
pluginI18nextExtractor,
165+
} from 'rsbuild-plugin-i18next-extractor';
166+
167+
function pluginObserveI18nExtraction(): RsbuildPlugin {
168+
return {
169+
name: 'example:observe-i18n-extraction',
170+
setup(api) {
171+
api.modifyBundlerChain((chain) => {
172+
chain
173+
.plugin('example:observe-i18n-extraction')
174+
.use(
175+
class ObserveI18nExtractionPlugin {
176+
apply(compiler: Rspack.Compiler) {
177+
compiler.hooks.compilation.tap(
178+
'example:observe-i18n-extraction',
179+
(compilation) => {
180+
const hooks =
181+
getI18nextExtractorWebpackPluginHooks(compilation);
182+
183+
hooks.afterExtract.tapPromise(
184+
'example:observe-i18n-extraction',
185+
async (payload) => {
186+
console.log(payload.entryName);
187+
console.log(payload.extractedTranslationsByLocale);
188+
return payload;
189+
},
190+
);
191+
192+
hooks.renderExtractedTranslations.tapPromise(
193+
'example:observe-i18n-extraction',
194+
async (payload) => {
195+
if (payload.locale === 'zh-CN') {
196+
return {
197+
...payload,
198+
skip: true,
199+
code: '',
200+
};
201+
}
202+
203+
return payload;
204+
},
205+
);
206+
},
207+
);
208+
}
209+
},
210+
);
211+
});
212+
},
213+
};
214+
}
215+
216+
export default defineConfig({
217+
plugins: [
218+
pluginI18nextExtractor({
219+
localesDir: './locales',
220+
}),
221+
pluginObserveI18nExtraction(),
222+
],
223+
});
224+
```
225+
226+
### Hook Semantics
227+
228+
- `afterExtract` is a waterfall hook and should return the payload it wants later consumers to receive.
229+
- `renderExtractedTranslations` is also a waterfall hook and should return the payload it wants the default emitter to use.
230+
- Setting `skip: true` or returning an empty `code` string prevents that locale payload from being injected into JS assets.
231+
- `targetAssetNames` contains all synchronous and async JS assets that would otherwise receive the injected definitions for the current entry.
232+
91233
## Options
92234

93235
### `localesDir`

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rsbuild-plugin-i18next-extractor",
3-
"version": "0.1.4",
3+
"version": "0.2.0",
44
"description": "A Rsbuild plugin for extracting i18n translations using i18next-cli",
55
"keywords": [
66
"rsbuild",

src/I18nextExtractorWebpackPlugin.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises';
22
import * as path from 'node:path';
33
import { createFilter } from '@rollup/pluginutils';
44
import type { Rspack } from '@rsbuild/core';
5+
import { getI18nextExtractorWebpackPluginHooks } from './hooks.js';
56
import type { PluginI18nextExtractorOptions } from './options.js';
67
import {
78
getLocalesFromDirectory,
@@ -37,6 +38,8 @@ export class I18nextExtractorWebpackPlugin {
3738
});
3839

3940
compiler.hooks.compilation.tap(this.constructor.name, (compilation) => {
41+
getI18nextExtractorWebpackPluginHooks(compilation);
42+
4043
const locales = getLocalesFromDirectory(
4144
compiler.context,
4245
this.options.localesDir,
@@ -76,6 +79,10 @@ export class I18nextExtractorWebpackPlugin {
7679
.map((file) => compilation.getAsset(file))
7780
.filter((file) => !!file);
7881

82+
const targetAssetNames = [...jsFiles, ...asyncJsFiles].map(
83+
(asset) => asset.name,
84+
);
85+
7986
// Collect all the modules belong to current entry
8087
const entryModules = new Set<string>();
8188
for (const chunk of entrypoint.chunks) {
@@ -123,9 +130,12 @@ export class I18nextExtractorWebpackPlugin {
123130
this.options.i18nextToolkitConfig,
124131
);
125132

126-
// Generate i18n resource definitions for each locale
127-
const i18nTranslationDefinitions: string[] = [];
133+
const extractedTranslationsByLocale: Record<
134+
string,
135+
Record<string, string>
136+
> = {};
128137

138+
// Generate i18n resource definitions for each locale
129139
for (const locale of locales) {
130140
const localeFilePath = resolveLocaleFilePath(
131141
this.options.localesDir,
@@ -156,6 +166,8 @@ export class I18nextExtractorWebpackPlugin {
156166
},
157167
);
158168

169+
extractedTranslationsByLocale[locale] = extractedTranslations;
170+
159171
// Write debug output to node_modules when DEBUG is enabled
160172
if (DEBUG) {
161173
try {
@@ -195,10 +207,45 @@ export class I18nextExtractorWebpackPlugin {
195207
);
196208
}
197209
}
210+
}
198211

199-
i18nTranslationDefinitions.push(
200-
`const ${getLocaleVariableName(locale)} = ${JSON.stringify(extractedTranslations)};`,
201-
);
212+
const hooks =
213+
getI18nextExtractorWebpackPluginHooks(compilation);
214+
const afterExtractPayload = await hooks.afterExtract.promise({
215+
entryName,
216+
locales,
217+
files,
218+
extractedKeysByLocale: extractedTranslationKeys,
219+
extractedTranslationsByLocale,
220+
});
221+
222+
const i18nTranslationDefinitions: string[] = [];
223+
for (const locale of afterExtractPayload.locales) {
224+
const variableName = getLocaleVariableName(locale);
225+
const renderedPayload =
226+
await hooks.renderExtractedTranslations.promise({
227+
entryName: afterExtractPayload.entryName,
228+
locale,
229+
variableName,
230+
extractedKeys:
231+
afterExtractPayload.extractedKeysByLocale[locale] ?? [],
232+
extractedTranslations:
233+
afterExtractPayload.extractedTranslationsByLocale[
234+
locale
235+
] ?? {},
236+
targetAssetNames,
237+
code: `const ${variableName} = ${JSON.stringify(
238+
afterExtractPayload.extractedTranslationsByLocale[
239+
locale
240+
] ?? {},
241+
)};`,
242+
});
243+
244+
if (renderedPayload.skip || !renderedPayload.code) {
245+
continue;
246+
}
247+
248+
i18nTranslationDefinitions.push(renderedPayload.code);
202249
}
203250

204251
// Replace the placeholder with actual extracted translations

src/hooks.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Rspack } from '@rsbuild/core';
2+
import { AsyncSeriesWaterfallHook } from '@rspack/lite-tapable';
3+
4+
export interface AfterExtractPayload {
5+
entryName: string;
6+
locales: string[];
7+
files: string[];
8+
extractedKeysByLocale: Record<string, string[]>;
9+
extractedTranslationsByLocale: Record<string, Record<string, string>>;
10+
}
11+
12+
export interface RenderExtractedTranslationsPayload {
13+
entryName: string;
14+
locale: string;
15+
variableName: string;
16+
extractedKeys: string[];
17+
extractedTranslations: Record<string, string>;
18+
targetAssetNames: string[];
19+
code: string;
20+
skip?: boolean;
21+
}
22+
23+
export interface I18nextExtractorWebpackPluginHooks {
24+
afterExtract: AsyncSeriesWaterfallHook<AfterExtractPayload>;
25+
renderExtractedTranslations: AsyncSeriesWaterfallHook<RenderExtractedTranslationsPayload>;
26+
}
27+
28+
const compilationHooksMap = new WeakMap<
29+
Rspack.Compilation,
30+
I18nextExtractorWebpackPluginHooks
31+
>();
32+
33+
export function getI18nextExtractorWebpackPluginHooks(
34+
compilation: Rspack.Compilation,
35+
): I18nextExtractorWebpackPluginHooks {
36+
let hooks = compilationHooksMap.get(compilation);
37+
38+
if (!hooks) {
39+
hooks = {
40+
afterExtract: new AsyncSeriesWaterfallHook<AfterExtractPayload>([
41+
'payload',
42+
]),
43+
renderExtractedTranslations:
44+
new AsyncSeriesWaterfallHook<RenderExtractedTranslationsPayload>([
45+
'payload',
46+
]),
47+
};
48+
compilationHooksMap.set(compilation, hooks);
49+
}
50+
51+
return hooks;
52+
}

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
export type { PluginI18nextExtractorOptions } from './options.js';
2+
export type {
3+
AfterExtractPayload,
4+
I18nextExtractorWebpackPluginHooks,
5+
RenderExtractedTranslationsPayload,
6+
} from './hooks.js';
7+
export { getI18nextExtractorWebpackPluginHooks } from './hooks.js';
28
export * from './pluginI18nextExtractor.js';

0 commit comments

Comments
 (0)