Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/feat-i18next-custom-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@lynx-js/i18next-translation-dedupe": patch
---

Introduce `@lynx-js/i18next-translation-dedupe` package to avoid bundling i18next translations twice in Lynx apps.

The package reads translations extracted by `rsbuild-plugin-i18next-extractor`, skips the extractor's default rendered asset, and writes the translations into the Lynx bundle custom section:

```json
{
"customSections": {
"i18next-translations": {
"content": {
"en-US": {
"hello": "Hello"
},
"zh-CN": {
"hello": "你好"
}
}
}
}
}
```
Comment thread
luhc228 marked this conversation as resolved.
5 changes: 5 additions & 0 deletions .github/i18n.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
applyTo: "packages/i18n/**"
---

Treat mini-apps under `tests/fixtures/**` as build fixtures rather than production source. When they are used only as integration-test inputs, prefer keeping them out of root ESLint coverage instead of forcing them to satisfy the full package source lint rules.
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ packages/repl/** @huxpro
packages/web-platform/** @pupiltong @Sherry-hue
packages/webpack/** @colinaaa @upupming @luhc228
packages/rspeedy/** @colinaaa @upupming @luhc228
packages/i18n/** @luhc228
packages/rspeedy/plugin-react/** @upupming
packages/react/** @hzy @HuJean @Yradex
packages/react/transform/** @gaoachao
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default tseslint.config(
'packages/**/vitest.config.ts',
'packages/react/runtime/compat/**',
'packages/rspeedy/create-rspeedy/template-*/**',
'packages/i18n/**/tests/fixtures/**',
'packages/{rspeedy,webpack}/*/test/**/cases/**',
'packages/{rspeedy,webpack}/*/test/**/hotCases/**',
'packages/{rspeedy,webpack}/*/test/**/diagnostic/**',
Expand Down
73 changes: 73 additions & 0 deletions packages/i18n/i18next-translation-dedupe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<h2 align="center">@lynx-js/i18next-translation-dedupe</h2>

Dedupe i18next translations between Rspeedy build output and Lynx runtime.

## Install

```bash
npm install i18next
npm install -D @lynx-js/i18next-translation-dedupe rsbuild-plugin-i18next-extractor i18next-cli
```

## Rspeedy Usage

```ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginI18nextExtractor } from 'rsbuild-plugin-i18next-extractor';
import { pluginLynxI18nextTranslationDedupe } from '@lynx-js/i18next-translation-dedupe';

export default defineConfig({
plugins: [
pluginI18nextExtractor({
localesDir: './src/locales',
}),
pluginLynxI18nextTranslationDedupe(),
],
});
```

`pluginLynxI18nextTranslationDedupe()` reads extracted translations from `rsbuild-plugin-i18next-extractor`, skips the extractor's default rendered asset to avoid bundling translations twice, and writes the translations into the Lynx bundle `customSections` for runtime loading.

## Runtime Usage

```ts
import i18next from 'i18next';
import { loadI18nextTranslations } from '@lynx-js/i18next-translation-dedupe';

await i18next.init({
lng: 'en-US',
fallbackLng: 'en-US',
resources: loadI18nextTranslations(),
});
```

## Custom Section Contract

The package uses one custom section key:

- `i18next-translations`

Its content is expected to be a locale-to-translation-object map:

```ts
{
'en-US': {
hello: 'Hello',
},
'zh-CN': {
hello: '你好',
},
}
```

At runtime, `loadI18nextTranslations()` converts that shape into i18next's `Resource` format:

```ts
{
'en-US': {
translation: {
hello: 'Hello',
},
},
}
```
52 changes: 52 additions & 0 deletions packages/i18n/i18next-translation-dedupe/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@lynx-js/i18next-translation-dedupe",
"version": "0.0.0",
"description": "Dedupe i18next translations in Lynx bundles.",
"keywords": [
"Lynx",
"i18next",
"rspeedy",
"rsbuild"
],
"repository": {
"type": "git",
"url": "https://github.com/lynx-family/lynx-stack.git",
"directory": "packages/i18n/i18next-translation-dedupe"
},
"license": "Apache-2.0",
"type": "module",
"exports": {
".": {
"types": "./lib/index.d.ts",
"import": "./lib/index.js"
},
"./package.json": "./package.json"
},
"types": "./lib/index.d.ts",
"files": [
"lib",
"!lib/**/*.js.map",
"CHANGELOG.md",
"README.md"
],
"scripts": {
"build": "tsc --build ./tsconfig.build.json",
"test": "vitest run"
},
"devDependencies": {
"@lynx-js/react": "workspace:*",
"@lynx-js/react-rsbuild-plugin": "workspace:*",
"@lynx-js/rspeedy": "workspace:*",
"@lynx-js/template-webpack-plugin": "workspace:*",
"@lynx-js/types": "3.7.0",
"i18next": "26.0.6",
"i18next-cli": "1.54.2",
"rsbuild-plugin-i18next-extractor": "0.2.0"
},
"peerDependencies": {
"rsbuild-plugin-i18next-extractor": "*"
},
Comment thread
luhc228 marked this conversation as resolved.
Comment thread
luhc228 marked this conversation as resolved.
Comment thread
luhc228 marked this conversation as resolved.
Comment thread
luhc228 marked this conversation as resolved.
"engines": {
"node": ">=18"
}
}
4 changes: 4 additions & 0 deletions packages/i18n/i18next-translation-dedupe/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
export const I18N_TRANSLATIONS_SECTION_KEY = 'i18next-translations';
6 changes: 6 additions & 0 deletions packages/i18n/i18next-translation-dedupe/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
export { I18N_TRANSLATIONS_SECTION_KEY } from './constants.js';
export { loadI18nextTranslations, toI18nextTranslations } from './runtime.js';
export { pluginLynxI18nextTranslationDedupe } from './plugin.js';
143 changes: 143 additions & 0 deletions packages/i18n/i18next-translation-dedupe/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { getI18nextExtractorWebpackPluginHooks } from 'rsbuild-plugin-i18next-extractor';
import type { AfterExtractPayload } from 'rsbuild-plugin-i18next-extractor';

import type { RsbuildPlugin, Rspack } from '@lynx-js/rspeedy';
import type {
LynxTemplatePlugin as LynxTemplatePluginClass,
TemplateHooks,
} from '@lynx-js/template-webpack-plugin';

import { I18N_TRANSLATIONS_SECTION_KEY } from './constants.js';

type I18nExtractionStore = Map<
string,
AfterExtractPayload['extractedTranslationsByLocale']
>;

type BeforeEncodeArgs = Parameters<
Parameters<TemplateHooks['beforeEncode']['tapPromise']>[1]
>[0];

interface LynxTemplatePluginExposure {
LynxTemplatePlugin: {
getLynxTemplatePluginHooks:
typeof LynxTemplatePluginClass.getLynxTemplatePluginHooks;
};
}

class LynxI18nextExtractorHooksWebpackPlugin {
constructor(private readonly store: I18nExtractionStore) {}

apply(compiler: Rspack.Compiler) {
compiler.hooks.compilation.tap(
'LynxI18nextExtractorHooksWebpackPlugin',
(compilation) => {
this.store.clear();

const hooks = getI18nextExtractorWebpackPluginHooks(compilation);

hooks.afterExtract.tap(
'LynxI18nextExtractorHooksWebpackPlugin',
(payload) => {
this.store.set(
payload.entryName,
payload.extractedTranslationsByLocale,
);

return payload;
},
);

hooks.renderExtractedTranslations.tapPromise(
'LynxI18nextExtractorHooksWebpackPlugin',
async (payload) => ({
...payload,
code: '',
skip: true,
}),
);
},
);
}
}

class LynxI18nextTranslationDedupeWebpackPlugin {
constructor(
private readonly store: I18nExtractionStore,
private readonly lynxTemplatePlugin:
LynxTemplatePluginExposure['LynxTemplatePlugin'],
) {}

apply(compiler: Rspack.Compiler) {
compiler.hooks.compilation.tap(
'LynxI18nextTranslationDedupeWebpackPlugin',
(compilation) => {
const hooks = this.lynxTemplatePlugin.getLynxTemplatePluginHooks(
compilation as unknown as Parameters<
LynxTemplatePluginExposure['LynxTemplatePlugin'][
'getLynxTemplatePluginHooks'
]
>[0],
);

hooks.beforeEncode.tap(
'LynxI18nextTranslationDedupeWebpackPlugin',
(args: BeforeEncodeArgs) => {
const entryName = args.entryNames[0];

if (!entryName) {
return args;
}

const translationsByLocale = this.store.get(entryName);

if (!translationsByLocale) {
return args;
}
Comment thread
luhc228 marked this conversation as resolved.

args.encodeData.customSections[I18N_TRANSLATIONS_SECTION_KEY] = {
content: translationsByLocale,
};

return args;
},
);
Comment thread
luhc228 marked this conversation as resolved.
},
);
}
}

export function pluginLynxI18nextTranslationDedupe(): RsbuildPlugin {
return {
name: 'lynx:i18next-translation-dedupe',
pre: ['lynx:react', 'rsbuild:i18next-extractor'],
setup(api) {
const store: I18nExtractionStore = new Map();
const templatePluginExposure = api.useExposed<LynxTemplatePluginExposure>(
Symbol.for('LynxTemplatePlugin'),
);

if (!templatePluginExposure) {
return;
}

const { LynxTemplatePlugin } = templatePluginExposure;

api.modifyBundlerChain((chain) => {
chain
.plugin('lynx:i18next-extractor-hooks')
.use(LynxI18nextExtractorHooksWebpackPlugin, [store]);

chain
.plugin('lynx:i18next-translation-dedupe')
.use(LynxI18nextTranslationDedupeWebpackPlugin, [
store,
LynxTemplatePlugin,
]);
});
},
} as RsbuildPlugin;
}
39 changes: 39 additions & 0 deletions packages/i18n/i18next-translation-dedupe/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

/// <reference types="@lynx-js/types" />

import type { Resource } from 'i18next';
import type { AfterExtractPayload } from 'rsbuild-plugin-i18next-extractor';

import { I18N_TRANSLATIONS_SECTION_KEY } from './constants.js';

type TranslationsByLocale =
AfterExtractPayload['extractedTranslationsByLocale'];

type LynxWithCustomSections = typeof lynx & {
getCustomSectionSync(sectionKey: string): TranslationsByLocale | undefined;
};
Comment thread
luhc228 marked this conversation as resolved.

export function toI18nextTranslations(
translationsByLocale: TranslationsByLocale,
): Resource {
return Object.fromEntries(
Object.entries(translationsByLocale).map(([locale, translations]) => [
locale,
{
translation: translations,
},
]),
);
}

export function loadI18nextTranslations(): Resource {
const raw = (lynx as LynxWithCustomSections).getCustomSectionSync(
I18N_TRANSLATIONS_SECTION_KEY,
);
const translationsByLocale = raw && typeof raw === 'object' ? raw : {};

return toI18nextTranslations(translationsByLocale);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { i18n } from './i18n.js';

export function App() {
return <text>{String(i18n.t('hello'))}</text>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { createInstance, type i18n as I18nInstance } from 'i18next';

import { loadI18nextTranslations } from '../../../../src/runtime.js';

export const i18n: I18nInstance = createInstance();

void i18n.init({
lng: 'en',
fallbackLng: 'en',
compatibilityJSON: 'v4',
resources: loadI18nextTranslations(),
});
Comment thread
luhc228 marked this conversation as resolved.
Loading
Loading