Skip to content

Commit 93c9067

Browse files
chore: Run i18n watcher concurrently with dev server
Adds `npm-run-all` to enable the i18n namespace index watcher script (`dev:i18n-watch`) to run in parallel with the Vite development server (`dev:vite`) using a single command. The `package.json` scripts have been updated: - The original `start` script (running `vite`) has been renamed to `dev:vite`. - A new `dev` script has been added: `"dev": "npm-run-all --parallel dev:vite dev:i18n-watch"`. You can now run `npm run dev` to start both the Vite server and the i18n watcher, streamlining the development setup.
1 parent 0965a33 commit 93c9067

File tree

6 files changed

+224
-67
lines changed

6 files changed

+224
-67
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@
7474
"lint": "eslint --fix \"{src,__tests__,types}/**/*.{ts,tsx,js,json}\"",
7575
"format": "prettier --write \"{src,__tests__,types}/**/*.{ts,tsx,js,json}\"",
7676
"test": "node ./test/RunTests.js",
77-
"build": "vite build",
78-
"start": "vite",
77+
"build": "node scripts/generate-i18n-indexes.js && vite build",
78+
"dev": "npm-run-all --parallel dev:vite dev:i18n-watch",
79+
"dev:vite": "vite",
80+
"dev:i18n-watch": "node scripts/generate-i18n-indexes.js --watch",
7981
"preview": "vite preview",
8082
"prepare": "husky install"
8183
},

scripts/generate-i18n-indexes.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs').promises;
4+
const path = require('path');
5+
const chokidar = require('chokidar');
6+
7+
const localesDir = path.join(__dirname, '..', 'public', 'locales');
8+
9+
/**
10+
* Recursively finds all .json files in a directory and its subdirectories,
11+
* generating namespace strings from their paths relative to the base language directory.
12+
*
13+
* @param {string} baseDir The base directory for the current language (e.g., public/locales/en).
14+
* @param {string} currentDir The directory currently being scanned.
15+
* @returns {Promise<string[]>} A promise that resolves to an array of namespace strings.
16+
*/
17+
async function getNamespaces(baseDir, currentDir) {
18+
let namespaces = [];
19+
try {
20+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
21+
for (const entry of entries) {
22+
const entryPath = path.join(currentDir, entry.name);
23+
if (entry.isDirectory()) {
24+
namespaces = namespaces.concat(await getNamespaces(baseDir, entryPath));
25+
} else if (entry.isFile() && entry.name.endsWith('.json') && entry.name !== 'index.json') {
26+
const relativePath = path.relative(baseDir, entryPath);
27+
const namespace = relativePath.replace(/\.json$/, '').replace(/\\/g, '/'); // Ensure POSIX paths for namespaces
28+
namespaces.push(namespace);
29+
}
30+
}
31+
} catch (error) {
32+
console.error(`Error reading directory ${currentDir}: ${error.message}`);
33+
// Decide if we should re-throw or return empty/partial results
34+
}
35+
return namespaces;
36+
}
37+
38+
/**
39+
* Main function to generate index.json files for all language directories.
40+
*/
41+
async function generateIndexes() {
42+
console.log(`Starting generation of index.json files in ${localesDir}...\n`);
43+
44+
try {
45+
const langDirs = await fs.readdir(localesDir, { withFileTypes: true });
46+
47+
for (const langDirEntry of langDirs) {
48+
if (langDirEntry.isDirectory()) {
49+
const langCode = langDirEntry.name;
50+
const langDirPath = path.join(localesDir, langCode);
51+
console.log(`Processing language: ${langCode}`);
52+
53+
const namespaces = await getNamespaces(langDirPath, langDirPath);
54+
55+
if (namespaces.length === 0) {
56+
console.log(` No JSON files found for language ${langCode} (excluding index.json). Skipping index generation.`);
57+
continue;
58+
}
59+
60+
namespaces.sort();
61+
62+
const indexFilePath = path.join(langDirPath, 'index.json');
63+
const jsonData = JSON.stringify(namespaces, null, 2);
64+
65+
try {
66+
await fs.writeFile(indexFilePath, jsonData);
67+
console.log(` Successfully generated index.json for ${langCode} at ${indexFilePath}`);
68+
} catch (writeError) {
69+
console.error(` Error writing index.json for ${langCode}: ${writeError.message}`);
70+
}
71+
console.log(' Namespaces found:', namespaces);
72+
console.log('\n');
73+
}
74+
}
75+
} catch (error) {
76+
if (error.code === 'ENOENT') {
77+
console.error(
78+
`Error: Locales directory not found at ${localesDir}. Please ensure this directory exists and contains language subdirectories.`
79+
);
80+
} else {
81+
console.error(`Error reading locales directory ${localesDir}: ${error.message}`);
82+
}
83+
process.exitCode = 1; // Indicate failure
84+
return;
85+
}
86+
87+
console.log('Finished generating all index.json files.');
88+
}
89+
90+
// Main execution logic
91+
async function main() {
92+
if (process.argv.includes('--watch')) {
93+
console.log('Initial generation before watching...');
94+
await generateIndexes();
95+
console.log('\nWatching for file changes in', localesDir, '(excluding index.json files)...\n');
96+
97+
const watcher = chokidar.watch(path.join(localesDir, '**/*.json'), {
98+
ignored: (filePath) => path.basename(filePath) === 'index.json',
99+
ignoreInitial: true, // Don't trigger for existing files on startup
100+
persistent: true,
101+
});
102+
103+
watcher
104+
.on('add', async (filePath) => {
105+
console.log(`File ${path.relative(localesDir, filePath)} has been added. Regenerating indexes...`);
106+
await generateIndexes();
107+
})
108+
.on('change', async (filePath) => {
109+
console.log(`File ${path.relative(localesDir, filePath)} has been changed. Regenerating indexes...`);
110+
await generateIndexes();
111+
})
112+
.on('unlink', async (filePath) => {
113+
console.log(`File ${path.relative(localesDir, filePath)} has been unlinked. Regenerating indexes...`);
114+
await generateIndexes();
115+
})
116+
.on('error', (error) => console.error(`Watcher error: ${error}`))
117+
.on('ready', () => console.log('Initial scan complete. Ready for changes.'));
118+
} else {
119+
await generateIndexes();
120+
}
121+
}
122+
123+
main().catch((error) => {
124+
console.error(`An unexpected error occurred: ${error.message}`);
125+
process.exitCode = 1; // Indicate failure
126+
});

src/App.tsx

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CssBaseline, ThemeProvider } from '@mui/material';
2-
import React, { Suspense, useEffect, useState } from 'react';
2+
import React, { useEffect, useState } from 'react';
33
import { I18nextProvider } from 'react-i18next';
44
import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom';
55
import { ToastContainer } from 'react-toastify';
@@ -9,7 +9,7 @@ import NavigationBar from './components/NavigationBar/NavigationBar';
99
import { AuthProvider } from './context/AuthContext';
1010
import { GlobalModalProvider } from './context/GlobalModalContext';
1111
import { useNotify } from './hooks/useNotify';
12-
import i18n from './i18n';
12+
import { i18n } from './i18n'; // Updated import
1313
import ErrorPage from './pages/Error';
1414
import HomePage from './pages/Home';
1515
import LoginProcessPage from './pages/LoginProcess';
@@ -29,10 +29,6 @@ const App: React.FC = () => {
2929
return isDarkMode === 'true';
3030
});
3131

32-
useEffect(() => {
33-
i18n.loadNamespaces('components/globalmodal');
34-
}, []);
35-
3632
useEffect(() => {
3733
const message = localStorage.getItem('logoutMessage');
3834
if (message) {
@@ -43,24 +39,25 @@ const App: React.FC = () => {
4339
}
4440
}, [notify]);
4541

46-
const storedLanguage = localStorage.getItem('RCBG_SELECTED_LANGUAGE');
47-
if (storedLanguage) {
48-
i18n.changeLanguage(storedLanguage);
49-
} else {
50-
i18n.changeLanguage('en');
51-
}
52-
5342
const toggleTheme = () => {
5443
setIsDarkMode((prev) => {
5544
localStorage.setItem('RCBG_IS_DARK_MODE', String(!prev));
5645
return !prev;
5746
});
5847
};
5948

49+
const handleChangeLanguage = async (newLang: string) => {
50+
await i18n.changeLanguage(newLang);
51+
localStorage.setItem('RCBG_SELECTED_LANGUAGE', newLang);
52+
};
53+
6054
// Define Navbar wrapper
6155
const NavbarWrapper = () => (
6256
<div>
63-
<NavigationBar toggleTheme={toggleTheme} />
57+
<NavigationBar
58+
toggleTheme={toggleTheme}
59+
handleChangeLanguage={handleChangeLanguage}
60+
/>
6461
<Outlet />
6562
</div>
6663
);
@@ -97,9 +94,7 @@ const App: React.FC = () => {
9794
<ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
9895
<GlobalModalProvider>
9996
<CssBaseline />
100-
<Suspense fallback={<div></div>}>
101-
<RouterProvider router={router} />
102-
</Suspense>
97+
<RouterProvider router={router} />
10398
<GlobalModal />
10499
</GlobalModalProvider>
105100
</ThemeProvider>

src/components/NavigationBar/NavigationBar.tsx

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ import { galleryApiFetch } from '@/utils';
3535
* - Mobile drawer with equivalent navigation and utility actions
3636
*
3737
* @param toggleTheme function to switch between light and dark modes
38+
* @param handleChangeLanguage function to change the application language
3839
*/
3940
const NavigationBar: React.FC<{
4041
toggleTheme: () => void;
41-
}> = ({ toggleTheme }) => {
42+
handleChangeLanguage: (newLang: string) => Promise<void>;
43+
}> = ({ toggleTheme, handleChangeLanguage }) => {
4244
// initialize translation for this component namespace
4345
const { t, i18n } = useTranslation('components/navigationbar');
4446

@@ -86,14 +88,6 @@ const NavigationBar: React.FC<{
8688
* Changes the application language, updates localStorage,
8789
* applies to i18n and shows a notification.
8890
*
89-
* @param lang locale code to switch to (e.g. 'en', 'zh')
90-
*/
91-
const changeLanguage = (lang: string) => {
92-
localStorage.setItem('RCBG_SELECTED_LANGUAGE', lang);
93-
i18n.changeLanguage(lang);
94-
notify(t('navigation_bar.language_updated_message'));
95-
};
96-
9791
// shared style for navigation links
9892
const generalNavLinkSx = {
9993
'&:visited': {
@@ -269,16 +263,18 @@ const NavigationBar: React.FC<{
269263
sx={{ mt: 1, zIndex: 9001 }}
270264
>
271265
<MenuItem
272-
onClick={() => {
273-
changeLanguage('en');
266+
onClick={async () => {
267+
await handleChangeLanguage('en');
268+
notify(t('navigation_bar.language_updated_message'));
274269
setLanguageMenuAnchor(null);
275270
}}
276271
>
277272
🇺🇸 English
278273
</MenuItem>
279274
<MenuItem
280-
onClick={() => {
281-
changeLanguage('zh');
275+
onClick={async () => {
276+
await handleChangeLanguage('zh');
277+
notify(t('navigation_bar.language_updated_message'));
282278
setLanguageMenuAnchor(null);
283279
}}
284280
>
@@ -396,16 +392,18 @@ const NavigationBar: React.FC<{
396392
sx={{ mt: 1, zIndex: 9001 }}
397393
>
398394
<MenuItem
399-
onClick={() => {
400-
changeLanguage('en');
395+
onClick={async () => {
396+
await handleChangeLanguage('en');
397+
notify(t('navigation_bar.language_updated_message'));
401398
setLanguageMenuAnchor(null);
402399
}}
403400
>
404401
🇺🇸 English
405402
</MenuItem>
406403
<MenuItem
407-
onClick={() => {
408-
changeLanguage('zh');
404+
onClick={async () => {
405+
await handleChangeLanguage('zh');
406+
notify(t('navigation_bar.language_updated_message'));
409407
setLanguageMenuAnchor(null);
410408
}}
411409
>

src/i18n.ts

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,61 @@
1-
import i18n from 'i18next';
1+
import i18next from 'i18next'; // Changed import
22
import LanguageDetector from 'i18next-browser-languagedetector';
33
import HttpApi from 'i18next-http-backend';
44
import { initReactI18next } from 'react-i18next';
55

6-
i18n
7-
.use(HttpApi)
8-
.use(LanguageDetector)
9-
.use(initReactI18next)
10-
.init({
11-
backend: {
12-
// Path to your translation JSON files
13-
loadPath: '/locales/{{lng}}/{{ns}}.json',
14-
},
6+
const i18n = i18next.createInstance(); // Create an instance
157

16-
// Fallback language
17-
debug: true,
8+
async function initializeI18n() {
9+
// Simplified language detection
10+
const detectedLng = localStorage.getItem('RCBG_SELECTED_LANGUAGE') || 'en';
11+
let fetchedNamespaces: string[] = [];
1812

19-
// Initial language
20-
fallbackLng: 'en',
13+
try {
14+
const response = await fetch(`/locales/${detectedLng}/index.json`);
15+
if (response.ok) {
16+
fetchedNamespaces = await response.json();
17+
} else {
18+
console.error(
19+
`Failed to fetch index.json for language ${detectedLng}. Status: ${response.status}. Using empty namespaces.`
20+
);
21+
// Potentially fetch fallbackLng's index.json here if desired
22+
// For now, fetchedNamespaces remains [] as per instruction
23+
}
24+
} catch (error) {
25+
console.error(`Error fetching index.json for language ${detectedLng}:`, error, '. Using empty namespaces.');
26+
// fetchedNamespaces remains []
27+
}
2128

22-
// Leave empty as namespaces will be dynamically loaded
23-
interpolation: {
24-
escapeValue: false, // React already escapes values
25-
},
29+
// If no namespaces were loaded (e.g. index.json missing or empty),
30+
// it might be good to load at least a 'common' or default namespace if your app has one.
31+
// For now, we proceed with potentially empty `fetchedNamespaces` as per current logic.
2632

27-
lng: 'en',
28-
// Enable debug mode
29-
ns: [],
30-
react: {
31-
useSuspense: true, // Enable suspense for lazy loading
32-
},
33-
});
33+
await i18n
34+
.use(HttpApi)
35+
.use(LanguageDetector) // LanguageDetector will override 'lng' if it runs after and detects a different language.
36+
// However, we are setting 'lng' explicitly based on our logic.
37+
// If LanguageDetector should be the source of truth after initial load,
38+
// the 'lng' option might not be needed or its value considered an initial hint.
39+
// For this setup, explicit 'lng' is used as per instructions.
40+
.use(initReactI18next)
41+
.init({
42+
lng: detectedLng, // Set the initial language explicitly
43+
ns: fetchedNamespaces, // Use dynamically fetched namespaces
44+
fallbackLng: 'en',
45+
backend: {
46+
loadPath: '/locales/{{lng}}/{{ns}}.json',
47+
},
48+
interpolation: {
49+
escapeValue: false, // React already escapes values
50+
},
51+
react: {
52+
useSuspense: false,
53+
},
54+
debug: true, // Or process.env.NODE_ENV === 'development'
55+
});
3456

35-
export default i18n;
57+
return i18n; // Return the initialized instance
58+
}
59+
60+
export default initializeI18n;
61+
export { i18n }; // Export the instance for use elsewhere

0 commit comments

Comments
 (0)