diff --git a/src/main/java/tech/jhipster/lite/generator/client/react/i18n/application/ReactI18nApplicationService.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/application/ReactI18nApplicationService.java
new file mode 100644
index 00000000000..588a845c9a7
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/application/ReactI18nApplicationService.java
@@ -0,0 +1,20 @@
+package tech.jhipster.lite.generator.client.react.i18n.application;
+
+import org.springframework.stereotype.Service;
+import tech.jhipster.lite.generator.client.react.i18n.domain.ReactI18nModuleFactory;
+import tech.jhipster.lite.module.domain.JHipsterModule;
+import tech.jhipster.lite.module.domain.properties.JHipsterModuleProperties;
+
+@Service
+public class ReactI18nApplicationService {
+
+ private final ReactI18nModuleFactory factory;
+
+ public ReactI18nApplicationService() {
+ factory = new ReactI18nModuleFactory();
+ }
+
+ public JHipsterModule buildModule(JHipsterModuleProperties properties) {
+ return factory.buildModule(properties);
+ }
+}
diff --git a/src/main/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactory.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactory.java
new file mode 100644
index 00000000000..c7043fcb28f
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactory.java
@@ -0,0 +1,77 @@
+package tech.jhipster.lite.generator.client.react.i18n.domain;
+
+import static tech.jhipster.lite.module.domain.JHipsterModule.*;
+
+import tech.jhipster.lite.module.domain.JHipsterModule;
+import tech.jhipster.lite.module.domain.file.JHipsterSource;
+import tech.jhipster.lite.module.domain.packagejson.VersionSource;
+import tech.jhipster.lite.module.domain.properties.JHipsterModuleProperties;
+import tech.jhipster.lite.shared.error.domain.Assert;
+
+public class ReactI18nModuleFactory {
+
+ private static final JHipsterSource APP_SOURCE = from("client/react/i18n/src/main/webapp/app");
+ private static final JHipsterSource ASSETS_FR_SOURCE = from("client/react/i18n/src/main/webapp/assets/locales/fr");
+ private static final JHipsterSource ASSETS_EN_SOURCE = from("client/react/i18n/src/main/webapp/assets/locales/en");
+
+ private static final String INDEX = "src/main/webapp/";
+ private static final String INDEX_TEST = "src/test/webapp/unit/common/primary/app/";
+
+ public JHipsterModule buildModule(JHipsterModuleProperties properties) {
+ Assert.notNull("properties", properties);
+
+ //@formatter:off
+ return moduleBuilder(properties)
+ .packageJson()
+ .addDependency(packageName("i18next"), VersionSource.COMMON)
+ .addDependency(packageName("i18next-browser-languagedetector"), VersionSource.COMMON)
+ .addDependency(packageName("i18next-http-backend"), VersionSource.COMMON)
+ .addDependency(packageName("react-i18next"), VersionSource.REACT)
+ .and()
+ .files()
+ .batch(APP_SOURCE, to(INDEX + "/app"))
+ .addFile("i18n.ts")
+ .and()
+ .batch(ASSETS_EN_SOURCE, to(INDEX + "assets/locales/en/"))
+ .addFile("translation.json")
+ .and()
+ .batch(ASSETS_FR_SOURCE, to(INDEX + "assets/locales/fr/"))
+ .addFile("translation.json")
+ .and()
+ .and()
+ .mandatoryReplacements()
+ .in(path(INDEX + "app/common/primary/app/App.tsx"))
+ .add(lineAfterText("import ReactLogo from '@assets/ReactLogo.png';"), "import { useTranslation } from 'react-i18next';")
+ .add(lineBeforeText("return ("), properties.indentation().times(1) + "const { t } = useTranslation();" + LINE_BREAK)
+ .add(lineAfterText(""), LINE_BREAK +
+ properties.indentation().times(4) + "
{t('translationEnabled')}
")
+ .and()
+ .in(path(INDEX + "app/index.tsx"))
+ .add(lineAfterText("import './index.css';"), "import './i18n';" + LINE_BREAK)
+ .and()
+ .in(path(INDEX_TEST + "App.spec.tsx"))
+ .add(append(), LINE_BREAK + """
+ describe('App I18next', () => {
+ it('renders with translation', () => {
+ vi.mock('react-i18next', () => ({
+ useTranslation: () => {
+ return {
+ t: vi.fn().mockImplementation((_str: string) => 'Internationalization enabled'),
+ i18n: {
+ changeLanguage: () => new Promise(() => {}),
+ },
+ };
+ },
+ }));
+ render();
+ const { getAllByText } = render();
+ const title = getAllByText('Internationalization enabled');
+ expect(title).toBeTruthy();
+ });
+ });""" )
+ .and()
+ .and()
+ .build();
+ //@formatter:off
+ }
+}
diff --git a/src/main/java/tech/jhipster/lite/generator/client/react/i18n/infrastructure/primary/ReactI18nModuleConfiguration.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/infrastructure/primary/ReactI18nModuleConfiguration.java
new file mode 100644
index 00000000000..4b245b4f8f5
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/infrastructure/primary/ReactI18nModuleConfiguration.java
@@ -0,0 +1,27 @@
+package tech.jhipster.lite.generator.client.react.i18n.infrastructure.primary;
+
+import static tech.jhipster.lite.generator.slug.domain.JHLiteFeatureSlug.CLIENT_INTERNATIONALIZATION;
+import static tech.jhipster.lite.generator.slug.domain.JHLiteModuleSlug.REACT_CORE;
+import static tech.jhipster.lite.generator.slug.domain.JHLiteModuleSlug.REACT_I18N;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import tech.jhipster.lite.generator.client.react.i18n.application.ReactI18nApplicationService;
+import tech.jhipster.lite.module.domain.resource.JHipsterModuleOrganization;
+import tech.jhipster.lite.module.domain.resource.JHipsterModulePropertiesDefinition;
+import tech.jhipster.lite.module.domain.resource.JHipsterModuleResource;
+
+@Configuration
+class ReactI18nModuleConfiguration {
+
+ @Bean
+ JHipsterModuleResource i18nModule(ReactI18nApplicationService i18n) {
+ return JHipsterModuleResource.builder()
+ .slug(REACT_I18N)
+ .propertiesDefinition(JHipsterModulePropertiesDefinition.builder().build())
+ .apiDoc("Frontend - React", "Add react internationalization")
+ .organization(JHipsterModuleOrganization.builder().feature(CLIENT_INTERNATIONALIZATION).addDependency(REACT_CORE).build())
+ .tags("client", "react", "i18n")
+ .factory(i18n::buildModule);
+ }
+}
diff --git a/src/main/java/tech/jhipster/lite/generator/client/react/i18n/package-info.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/package-info.java
new file mode 100644
index 00000000000..b0d7de1f18a
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/package-info.java
@@ -0,0 +1,2 @@
+@tech.jhipster.lite.BusinessContext
+package tech.jhipster.lite.generator.client.react.i18n;
diff --git a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java
index 7432d9bc5f9..c358958b55a 100644
--- a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java
+++ b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java
@@ -8,6 +8,7 @@ public enum JHLiteFeatureSlug implements JHipsterFeatureSlugFactory {
AUTHENTICATION_SPRINGDOC("authentication-springdoc"),
JCACHE("jcache"),
CLIENT_CORE("client-core"),
+ CLIENT_INTERNATIONALIZATION("client-internationalization"),
CUCUMBER_AUTHENTICATION("cucumber-authentication"),
DATABASE_MIGRATION("database-migration"),
DOCKERFILE("dockerfile"),
diff --git a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java
index a7326069bf2..70958adfb86 100644
--- a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java
+++ b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java
@@ -74,7 +74,6 @@ public enum JHLiteModuleSlug implements JHipsterModuleSlugFactory {
MAVEN_WRAPPER("maven-wrapper"),
MONGOCK("mongock"),
MONGODB("mongodb"),
- REDIS("redis"),
MSSQL("mssql"),
MYSQL("mysql"),
NEO4J("neo4j"),
@@ -87,7 +86,9 @@ public enum JHLiteModuleSlug implements JHipsterModuleSlugFactory {
PROTOBUF("protobuf"),
PROTOBUF_BACKWARDS_COMPATIBILITY_CHECK("protobuf-backwards-compatibility-check"),
REACT_CORE("react-core"),
+ REACT_I18N("react-i18next"),
REACT_JWT("react-jwt"),
+ REDIS("redis"),
REST_PAGINATION("rest-pagination"),
SAMPLE_CASSANDRA_PERSISTENCE("sample-cassandra-persistence"),
SAMPLE_FEATURE("sample-feature"),
diff --git a/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java b/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java
index b46ccf365e4..768f5f7ae02 100644
--- a/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java
+++ b/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java
@@ -290,6 +290,10 @@ public static RegexNeedleAfterReplacer lineAfterRegex(String regex) {
return new RegexNeedleAfterReplacer(notContainingReplacement(), Pattern.compile(regex, Pattern.MULTILINE));
}
+ public static EndOfFileReplacer append() {
+ return new EndOfFileReplacer(ReplacementCondition.always());
+ }
+
public static BuildProfileId buildProfileId(String id) {
return new BuildProfileId(id);
}
diff --git a/src/main/resources/generator/client/common/reacti18n/src/main/webapp/app/i18n.ts.mustache b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/app/i18n.ts.mustache
new file mode 100644
index 00000000000..738c25d7376
--- /dev/null
+++ b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/app/i18n.ts.mustache
@@ -0,0 +1,24 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import Backend from 'i18next-http-backend';
+import LanguageDetector from 'i18next-browser-languagedetector';
+
+i18n
+ .use(Backend)
+ .use(LanguageDetector)
+ .use(initReactI18next)
+
+ .init({
+ fallbackLng: 'en',
+ debug: false,
+
+ interpolation: {
+ escapeValue: false,
+ },
+ backend: {
+ loadPath: '../assets/locales/{{ lng }}/{{ ns }}.json',
+ },
+ });
+
+export default i18n;
diff --git a/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/english.json.mustache b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/english.json.mustache
new file mode 100644
index 00000000000..1d29147104b
--- /dev/null
+++ b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/english.json.mustache
@@ -0,0 +1,3 @@
+{
+ "translationEnabled": "Internationalization enabled"
+}
diff --git a/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/french.json.mustache b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/french.json.mustache
new file mode 100644
index 00000000000..f4c3ffcea83
--- /dev/null
+++ b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/french.json.mustache
@@ -0,0 +1,3 @@
+{
+ "translationEnabled": "Internationalisation activée"
+}
diff --git a/src/main/resources/generator/client/react/i18n/src/main/webapp/app/i18n.ts b/src/main/resources/generator/client/react/i18n/src/main/webapp/app/i18n.ts
new file mode 100644
index 00000000000..738c25d7376
--- /dev/null
+++ b/src/main/resources/generator/client/react/i18n/src/main/webapp/app/i18n.ts
@@ -0,0 +1,24 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import Backend from 'i18next-http-backend';
+import LanguageDetector from 'i18next-browser-languagedetector';
+
+i18n
+ .use(Backend)
+ .use(LanguageDetector)
+ .use(initReactI18next)
+
+ .init({
+ fallbackLng: 'en',
+ debug: false,
+
+ interpolation: {
+ escapeValue: false,
+ },
+ backend: {
+ loadPath: '../assets/locales/{{ lng }}/{{ ns }}.json',
+ },
+ });
+
+export default i18n;
diff --git a/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/en/translation.json b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/en/translation.json
new file mode 100644
index 00000000000..1d29147104b
--- /dev/null
+++ b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/en/translation.json
@@ -0,0 +1,3 @@
+{
+ "translationEnabled": "Internationalization enabled"
+}
diff --git a/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/fr/translation.json b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/fr/translation.json
new file mode 100644
index 00000000000..f4c3ffcea83
--- /dev/null
+++ b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/fr/translation.json
@@ -0,0 +1,3 @@
+{
+ "translationEnabled": "Internationalisation activée"
+}
diff --git a/src/main/resources/generator/dependencies/common/package.json b/src/main/resources/generator/dependencies/common/package.json
index 8b3b8a33f94..0fef04e77d4 100644
--- a/src/main/resources/generator/dependencies/common/package.json
+++ b/src/main/resources/generator/dependencies/common/package.json
@@ -3,6 +3,11 @@
"version": "0.0.0",
"description": "JHipster Lite : used for Dependencies",
"license": "Apache-2.0",
+ "dependencies": {
+ "i18next": "23.14.0",
+ "i18next-browser-languagedetector": "8.0.0",
+ "i18next-http-backend": "2.6.1"
+ },
"devDependencies": {
"@babel/cli": "7.24.8",
"@playwright/test": "1.46.1",
diff --git a/src/main/resources/generator/dependencies/react/package.json b/src/main/resources/generator/dependencies/react/package.json
index cc7a65d63cc..0ee7c85f9fe 100644
--- a/src/main/resources/generator/dependencies/react/package.json
+++ b/src/main/resources/generator/dependencies/react/package.json
@@ -9,7 +9,8 @@
"axios": "1.7.4",
"react": "18.3.1",
"react-dom": "18.3.1",
- "react-hook-form": "7.52.2"
+ "react-hook-form": "7.52.2",
+ "react-i18next": "15.0.1"
},
"devDependencies": {
"@testing-library/dom": "10.4.0",
diff --git a/src/test/features/client/reacti18n.feature b/src/test/features/client/reacti18n.feature
new file mode 100644
index 00000000000..38eaf1d4b93
--- /dev/null
+++ b/src/test/features/client/reacti18n.feature
@@ -0,0 +1,13 @@
+Feature: React i18n
+
+ Scenario: Should apply react i18n module to react
+ When I apply modules to default project
+ | init |
+ | react-core |
+ | react-i18next |
+ Then I should have files in "src/main/webapp/app"
+ | i18n.ts |
+ And I should have files in "src/main/webapp/assets/locales/en"
+ | translation.json |
+ And I should have files in "src/main/webapp/assets/locales/fr"
+ | translation.json |
diff --git a/src/test/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactoryTest.java b/src/test/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactoryTest.java
new file mode 100644
index 00000000000..6ef3d826463
--- /dev/null
+++ b/src/test/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactoryTest.java
@@ -0,0 +1,104 @@
+package tech.jhipster.lite.generator.client.react.i18n.domain;
+
+import static tech.jhipster.lite.module.infrastructure.secondary.JHipsterModulesAssertions.*;
+
+import org.junit.jupiter.api.Test;
+import tech.jhipster.lite.TestFileUtils;
+import tech.jhipster.lite.UnitTest;
+import tech.jhipster.lite.module.domain.JHipsterModule;
+import tech.jhipster.lite.module.domain.JHipsterModulesFixture;
+
+@UnitTest
+public class ReactI18nModuleFactoryTest {
+
+ public static final ReactI18nModuleFactory factory = new ReactI18nModuleFactory();
+
+ private static final String APP_TSX = "src/main/webapp/app/common/primary/app/App.tsx";
+
+ @Test
+ void shouldBuildI18nModule() {
+ JHipsterModule module = factory.buildModule(
+ JHipsterModulesFixture.propertiesBuilder(TestFileUtils.tmpDirForTest()).projectBaseName("jhipster").build()
+ );
+
+ JHipsterModuleAsserter asserter = assertThatModuleWithFiles(module, packageJsonFile(), app(), appTest(), index());
+
+ asserter
+ .hasFile("package.json")
+ .containing(nodeDependency("i18next"))
+ .containing(nodeDependency("i18next-browser-languagedetector"))
+ .containing(nodeDependency("i18next-http-backend"))
+ .containing(nodeDependency("react-i18next"))
+ .and()
+ .hasFile("src/main/webapp/app/i18n.ts")
+ .containing(
+ """
+ import i18n from 'i18next';
+ import { initReactI18next } from 'react-i18next';
+
+ import Backend from 'i18next-http-backend';
+ import LanguageDetector from 'i18next-browser-languagedetector';
+
+ i18n
+ .use(Backend)
+ .use(LanguageDetector)
+ .use(initReactI18next)
+
+ .init({
+ fallbackLng: 'en',
+ debug: false,
+
+ interpolation: {
+ escapeValue: false,
+ },
+ backend: {
+ loadPath: '../assets/locales/{{ lng }}/{{ ns }}.json',
+ },
+ });
+
+ export default i18n;
+ """
+ )
+ .and()
+ .hasFile("src/main/webapp/app/index.tsx")
+ .containing("import './i18n'")
+ .and()
+ .hasFile("src/main/webapp/app/common/primary/app/App.tsx")
+ .containing("import { useTranslation } from 'react-i18next")
+ .containing("const { t } = useTranslation();")
+ .containing("{t('translationEnabled')}")
+ .and()
+ .hasFile("src/main/webapp/assets/locales/en/translation.json")
+ .containing(
+ """
+ {
+ "translationEnabled": "Internationalization enabled"
+ }
+ """
+ )
+ .and()
+ .hasFile("src/main/webapp/assets/locales/fr/translation.json")
+ .containing(
+ """
+ {
+ "translationEnabled": "Internationalisation activée"
+ }
+ """
+ )
+ .and()
+ .hasFile("src/test/webapp/unit/common/primary/app/App.spec.tsx")
+ .containing("describe('App I18next', () => {");
+ }
+
+ private ModuleFile app() {
+ return file("src/test/resources/projects/react-app/App.tsx", APP_TSX);
+ }
+
+ private ModuleFile appTest() {
+ return file("src/test/resources/projects/react-app/App.spec.tsx", "src/test/webapp/unit/common/primary/app/App.spec.tsx");
+ }
+
+ private ModuleFile index() {
+ return file("src/test/resources/projects/react-app/index.tsx", "src/main/webapp/app/index.tsx");
+ }
+}
diff --git a/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java b/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java
index 3060383d862..5d28245b12c 100644
--- a/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java
+++ b/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java
@@ -42,6 +42,15 @@ void shouldGetVersionFromSource() {
assertThat(version).isEqualTo(new NpmPackageVersion("1.2.3"));
}
+ @Test
+ void shouldGetVersionFromEmptySourceWithEmptyDevSource() {
+ emptyProjectFiles();
+
+ NpmPackageVersion version = reader.get().get(new NpmPackageName("vue"), NpmVersionSource.COMMON);
+
+ assertThat(version).isEqualTo(new NpmPackageVersion("1.2.3"));
+ }
+
private void mockProjectFiles() {
when(projectFiles.readString(anyString())).thenReturn(
"""
@@ -80,4 +89,20 @@ private void mockProjectFiles() {
"""
);
}
+
+ private void emptyProjectFiles() {
+ when(projectFiles.readString(anyString())).thenReturn(
+ """
+ {
+ "name": "jhlite-dependencies",
+ "version": "0.0.0",
+ "description": "JHipster Lite : used for Dependencies",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "vue": "1.2.3"
+ },
+ }
+ """
+ );
+ }
}
diff --git a/src/test/resources/projects/react-app/App.spec.tsx b/src/test/resources/projects/react-app/App.spec.tsx
new file mode 100644
index 00000000000..f5e87d503ed
--- /dev/null
+++ b/src/test/resources/projects/react-app/App.spec.tsx
@@ -0,0 +1,12 @@
+import { render } from '@testing-library/react';
+import { describe, it } from 'vitest';
+
+import App from '@/common/primary/app/App';
+
+describe('App tests', () => {
+ it('renders without crashing', () => {
+ const { getByText } = render();
+ const title = getByText('React + TypeScript + Vite');
+ expect(title).toBeTruthy();
+ });
+});