diff --git a/docs/how_tos/migrate-frontend-app.md b/docs/how_tos/migrate-frontend-app.md index 68df23b6..f41d60e5 100644 --- a/docs/how_tos/migrate-frontend-app.md +++ b/docs/how_tos/migrate-frontend-app.md @@ -269,6 +269,8 @@ packages/ ### i18n ### src/i18n/transifex_input.json +src/i18n/messages.ts +src/i18n/messages/ ### Editors ### .DS_Store @@ -538,51 +540,45 @@ i18n Description fields are now required on all i18n messages in the repository. This is because of a change to the ESLint config. -Also, replace the contents of `src/i18n/index.js` with: - -``` -// Placeholder be overridden by `make pull_translations` -export default { - ar: {}, - 'zh-hk': {}, - 'zh-cn': {}, - uk: {}, - 'tr-tr': {}, - th: {}, - te: {}, - ru: {}, - 'pt-pt': {}, - 'pt-br': {}, - 'it-it': {}, - id: {}, - hi: {}, - he: {}, - 'fr-ca': {}, - fa: {}, - 'es-es': {}, - 'es-419': {}, - el: {}, - 'de-de': {}, - da: {}, - bo: {}, -}; +Translations are now pulled and prepared using the `openedx translations:pull` CLI command. Add an `atlasTranslations` field to your `package.json` so the command knows where to find your app's translations and which dependencies to resolve transitively: + +```json +"atlasTranslations": { + "path": "translations/frontend-app-[YOUR_APP]/src/i18n/messages", + "dependencies": ["@openedx/frontend-base"] +} ``` -Finally, edit the `Makefile` so that no strings are being pulled from `frontend-component-(header|footer)`, and rename `frontend-platform` to `frontend-base`. Such as: +Also add a `translations:pull` script to your `package.json`: -```Makefile -# Pulls translations using atlas. -pull_translations: - mkdir src/i18n/messages - cd src/i18n/messages \ - && atlas pull $(ATLAS_OPTIONS) \ - translations/frontend-base/src/i18n/messages:frontend-base \ - translations/paragon/src/i18n/messages:paragon \ - translations/frontend-app-[YOUR_APP]/src/i18n/messages:frontend-app-[YOUR_APP] +```json +"scripts": { + "translations:pull": "openedx translations:pull" +} +``` + +And update your `pull_translations` Makefile target to use it: - $(intl_imports) frontend-base paragon frontend-app-[YOUR_APP] +```Makefile +pull_translations: | requirements + npm run translations:pull ``` + +Running `npm run translations:pull` will pull translations from `openedx-translations` and generate `src/i18n/messages.ts`. + +Add a `src/i18n/index.ts` file that re-exports the generated messages: + +```ts +export { default } from './messages'; ``` + +Also add a `src/i18n/messages.d.ts` type declaration file so TypeScript knows the shape of the generated module even before `translations:pull` has been run: + +```ts +import type { SiteMessages } from '@openedx/frontend-base'; + +declare const messages: SiteMessages; +export default messages; ``` SVGR "ReactComponent" imports have been removed diff --git a/frontend-base.d.ts b/frontend-base.d.ts index c0baca11..f288e90d 100644 --- a/frontend-base.d.ts +++ b/frontend-base.d.ts @@ -2,6 +2,10 @@ declare module 'site.config' { export default SiteConfig; } +declare module 'site.i18n' { + export default SiteMessages; +} + declare module '*.svg' { const content: string; export default content; diff --git a/package-lock.json b/package-lock.json index 3f5480bb..a66c277e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,9 +96,7 @@ "webpack-remove-empty-scripts": "1.0.4" }, "bin": { - "intl-imports.js": "dist/tools/cli/intl-imports.js", - "openedx": "dist/tools/cli/openedx.js", - "transifex-utils.js": "dist/tools/cli/transifex-utils.js" + "openedx": "dist/tools/cli/openedx.js" }, "devDependencies": { "@edx/browserslist-config": "^1.5.0", @@ -106,12 +104,12 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@tsconfig/node20": "^20.1.5", + "@tsconfig/node24": "^24.0.4", "@types/compression": "^1.7.5", "@types/jest": "^29.5.14", "@types/lodash.camelcase": "^4.3.9", "@types/lodash.merge": "^4.6.9", - "@types/node": "^18.19.43", + "@types/node": "^24.12.0", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", "axios-mock-adapter": "^1.22.0", @@ -120,7 +118,7 @@ "nodemon": "^3.1.4" }, "peerDependencies": { - "@openedx/paragon": "^23.4.5", + "@openedx/paragon": "^23.20.0", "@tanstack/react-query": "^5.81.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -5193,10 +5191,10 @@ "node": ">= 10" } }, - "node_modules/@tsconfig/node20": { - "version": "20.1.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.9.tgz", - "integrity": "sha512-IjlTv1RsvnPtUcjTqtVsZExKVq+KQx4g5pCP5tI7rAs6Xesl2qFwSz/tPDBC4JajkL/MlezBu3gPUwqRHl+RIg==", + "node_modules/@tsconfig/node24": { + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node24/-/node24-24.0.4.tgz", + "integrity": "sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==", "dev": true, "license": "MIT" }, @@ -5605,12 +5603,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~7.16.0" } }, "node_modules/@types/parse-json": { @@ -20198,9 +20196,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { diff --git a/package.json b/package.json index 67b09fdb..09677003 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,13 @@ "/dist" ], "bin": { - "intl-imports.js": "./dist/tools/cli/intl-imports.js", - "openedx": "./dist/tools/cli/openedx.js", - "transifex-utils.js": "./dist/tools/cli/transifex-utils.js" + "openedx": "./dist/tools/cli/openedx.js" + }, + "atlasTranslations": { + "path": "translations/frontend-base/src/i18n/messages", + "dependencies": [ + "@openedx/paragon" + ] }, "scripts": { "build": "make build", @@ -26,7 +30,9 @@ "dev": "npm run build && node ./dist/tools/cli/openedx.js dev:shell", "docs": "jsdoc -c jsdoc.json", "lint": "eslint .; npm run lint:tools; npm --prefix ./test-site run lint", + "lint:fix": "eslint . --fix; npm run lint:fix:tools", "lint:tools": "cd ./tools && eslint . && cd ..", + "lint:fix:tools": "cd ./tools && eslint . --fix && cd ..", "pack": "mkdir -p pack && npm pack --silent --pack-destination pack >/dev/null && mv \"$(ls -t pack/*.tgz | head -n 1)\" pack/openedx-frontend-base.tgz", "prepack": "npm run build", "test": "jest", @@ -143,12 +149,12 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@tsconfig/node20": "^20.1.5", + "@tsconfig/node24": "^24.0.4", "@types/compression": "^1.7.5", "@types/jest": "^29.5.14", "@types/lodash.camelcase": "^4.3.9", "@types/lodash.merge": "^4.6.9", - "@types/node": "^18.19.43", + "@types/node": "^24.12.0", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", "axios-mock-adapter": "^1.22.0", @@ -157,7 +163,7 @@ "nodemon": "^3.1.4" }, "peerDependencies": { - "@openedx/paragon": "^23.4.5", + "@openedx/paragon": "^23.20.0", "@tanstack/react-query": "^5.81.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/runtime/i18n/index.js b/runtime/i18n/index.js index ffaa6ee6..0aa71cc2 100644 --- a/runtime/i18n/index.js +++ b/runtime/i18n/index.js @@ -97,7 +97,6 @@ export { } from 'react-intl'; export { - addAppMessages, configureI18n, getLocale, getLocalizedLanguageName, diff --git a/runtime/i18n/lib.ts b/runtime/i18n/lib.ts index 9ac6eff2..b8ee79fa 100644 --- a/runtime/i18n/lib.ts +++ b/runtime/i18n/lib.ts @@ -237,20 +237,6 @@ export function mergeMessages(newMessages = {}) { return messages; } -/** - * Adds all the messages found in the loaded apps. - * - * @memberof module:Internationalization - */ -export function addAppMessages() { - const { apps } = getSiteConfig(); - if (apps) { - apps.forEach((app) => { - mergeMessages(app.messages); - }); - } -} - interface ConfigureI18nOptions { messages: LocalizedMessages[] | LocalizedMessages, } diff --git a/shell/dev/devHome/app.ts b/shell/dev/devHome/app.ts index 2beb9c42..1cb30122 100644 --- a/shell/dev/devHome/app.ts +++ b/shell/dev/devHome/app.ts @@ -1,6 +1,5 @@ import { App } from '../../../types'; import HomePage from './HomePage'; -import messages from './i18n'; const app: App = { appId: 'org.openedx.frontend.app.dev.home', @@ -12,7 +11,6 @@ const app: App = { role: 'org.openedx.frontend.role.devHome' } }], - messages, }; export default app; diff --git a/shell/dev/devHome/i18n/index.ts b/shell/dev/devHome/i18n/index.ts deleted file mode 100644 index 9976985c..00000000 --- a/shell/dev/devHome/i18n/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Placeholder be overridden by `make pull_translations` -export default { - ar: {}, - 'zh-hk': {}, - 'zh-cn': {}, - uk: {}, - 'tr-tr': {}, - th: {}, - te: {}, - ru: {}, - 'pt-pt': {}, - 'pt-br': {}, - 'it-it': {}, - id: {}, - hi: {}, - he: {}, - 'fr-ca': { - 'home.content': 'Home content in French.' - }, - fa: {}, - 'es-es': {}, - 'es-419': {}, - el: {}, - 'de-de': {}, - da: {}, - bo: {}, -}; diff --git a/shell/i18n/index.ts b/shell/i18n/index.ts deleted file mode 100644 index 883a77a4..00000000 --- a/shell/i18n/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Placeholder be overridden by `make pull_translations` -export default { - ar: {}, - 'zh-hk': {}, - 'zh-cn': {}, - uk: {}, - 'tr-tr': {}, - th: {}, - te: {}, - ru: {}, - 'pt-pt': {}, - 'pt-br': {}, - 'it-it': {}, - id: {}, - hi: {}, - he: {}, - 'fr-ca': {}, - fa: {}, - 'es-es': {}, - 'es-419': {}, - el: {}, - 'de-de': {}, - da: {}, - bo: {}, -}; diff --git a/shell/site.tsx b/shell/site.tsx index 148859f0..6b1319ad 100644 --- a/shell/site.tsx +++ b/shell/site.tsx @@ -10,8 +10,7 @@ import { subscribe } from '../runtime'; import { addAppConfigs } from '../runtime/config'; -import { addAppMessages } from '../runtime/i18n'; -import messages from './i18n'; +import messages from 'site.i18n'; import createRouter from './router/createRouter'; subscribe(SITE_READY, async () => { @@ -19,7 +18,6 @@ subscribe(SITE_READY, async () => { const router = createRouter(); addAppConfigs(); - addAppMessages(); const root = createRoot(document.getElementById('root') as HTMLElement); root.render( diff --git a/test-site/src/authenticated-page/i18n/index.ts b/test-site/src/authenticated-page/i18n/index.ts deleted file mode 100644 index 0f06c797..00000000 --- a/test-site/src/authenticated-page/i18n/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Placeholder be overridden by `make pull_translations` -export default { - ar: {}, - 'zh-hk': {}, - 'zh-cn': {}, - uk: {}, - 'tr-tr': {}, - th: {}, - te: {}, - ru: {}, - 'pt-pt': {}, - 'pt-br': {}, - 'it-it': {}, - id: {}, - hi: {}, - he: {}, - 'fr-ca': { - 'authenticated.page.content': 'This is a localized message in French.' - }, - fa: {}, - 'es-es': {}, - 'es-419': {}, - el: {}, - 'de-de': {}, - da: {}, - bo: {}, -}; diff --git a/test-site/src/authenticated-page/index.tsx b/test-site/src/authenticated-page/index.tsx index 55332178..28d3d34e 100644 --- a/test-site/src/authenticated-page/index.tsx +++ b/test-site/src/authenticated-page/index.tsx @@ -1,5 +1,4 @@ import { App, LinkMenuItem, WidgetOperationTypes } from '@openedx/frontend-base'; -import messages from './i18n'; const config: App = { appId: 'test-authenticated-page-app', @@ -22,7 +21,6 @@ const config: App = { ) } ], - messages, }; export default config; diff --git a/test-site/src/i18n/README.md b/test-site/src/i18n/README.md deleted file mode 100644 index df5f0812..00000000 --- a/test-site/src/i18n/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Test i18n directories - -These test files are used by the `src/i18n/scripts/intl-imports.test.js` file. diff --git a/test-site/src/i18n/index.ts b/test-site/src/i18n/index.ts new file mode 100644 index 00000000..d6d1738d --- /dev/null +++ b/test-site/src/i18n/index.ts @@ -0,0 +1 @@ +export default []; diff --git a/test-site/src/i18n/messages/frontend-app-sample/ar.json b/test-site/src/i18n/messages/frontend-app-sample/ar.json deleted file mode 100644 index 70720d88..00000000 --- a/test-site/src/i18n/messages/frontend-app-sample/ar.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "learning.accessExpiration.deadline": "قم بالترقية قبل {date} للاستفادة من دخول غير محدود للمساق طالما هو موجود على الموقع.", - "learning.accessExpiration.header": "تنتهي صلاحية دخول المساق كمستمع في {date}" -} \ No newline at end of file diff --git a/test-site/src/i18n/messages/frontend-app-sample/eo.json b/test-site/src/i18n/messages/frontend-app-sample/eo.json deleted file mode 100644 index 9e26dfee..00000000 --- a/test-site/src/i18n/messages/frontend-app-sample/eo.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test-site/src/i18n/messages/frontend-app-sample/es_419.json b/test-site/src/i18n/messages/frontend-app-sample/es_419.json deleted file mode 100644 index bfc2b52f..00000000 --- a/test-site/src/i18n/messages/frontend-app-sample/es_419.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "learning.accessExpiration.deadline": "Mejora de categoría antes del {fecha} para obtener acceso ilimitado al curso mientras exista en el sitio.", - "learning.accessExpiration.header": "El acceso a tomar el curso de forma gratuita expira el {fecha}" -} \ No newline at end of file diff --git a/test-site/src/i18n/messages/frontend-component-emptylangs/ar.json b/test-site/src/i18n/messages/frontend-component-emptylangs/ar.json deleted file mode 100644 index 9e26dfee..00000000 --- a/test-site/src/i18n/messages/frontend-component-emptylangs/ar.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test-site/src/i18n/messages/frontend-component-nolangs/.gitignore b/test-site/src/i18n/messages/frontend-component-nolangs/.gitignore deleted file mode 100644 index 4a119cc6..00000000 --- a/test-site/src/i18n/messages/frontend-component-nolangs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -# Placeholder file \ No newline at end of file diff --git a/test-site/src/i18n/messages/frontend-component-singlelang/ar.json b/test-site/src/i18n/messages/frontend-component-singlelang/ar.json deleted file mode 100644 index babfb6d5..00000000 --- a/test-site/src/i18n/messages/frontend-component-singlelang/ar.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "learning.accessExpiration.header3": "تنتهي صلاحية دخول المساق كمستمع في {date}" -} \ No newline at end of file diff --git a/tools/cli/README.md b/tools/cli/README.md index 7b8d6d17..35ae430a 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -1,31 +1,18 @@ # cli -This directory contains the `transifex-utils.js` and `intl-imports.js` files which are shared across all micro-frontends. +This directory contains the `openedx` CLI which is shared across all sites and micro-frontends. -The package.json of `frontend-base` includes the following sections: +The package.json of `frontend-base` includes the following section: ``` "bin": { - "intl-imports.js": "dist/tools/cli/intl-imports.js", - "openedx": "dist/tools/cli/openedx.js", - "transifex-utils.js": "dist/tools/cli/transifex-utils.js" + "openedx": "dist/tools/cli/openedx.js" }, ``` -This config block causes the scripts to be copied to the following path when `frontend-base` is installed as a -dependency of a micro-frontend: +This config block causes the CLI to be available at the following path when `frontend-base` is installed as a +dependency of a site or micro-frontend: ``` -/node_modules/.bin/intl-imports.js /node_modules/.bin/openedx -/node_modules/.bin/transifex-utils.js ``` - -All micro-frontends have a `Makefile` with a line that loads the scripts from the above path: - -``` -intl_imports = ./node_modules/.bin/intl-imports.js -transifex_utils = ./node_modules/.bin/transifex-utils.js -``` - -So if you delete either of the files or the `cli` directory, you'll break all micro-frontend builds. Happy coding! diff --git a/tools/cli/commands/translations.test.ts b/tools/cli/commands/translations.test.ts new file mode 100644 index 00000000..6ba052b9 --- /dev/null +++ b/tools/cli/commands/translations.test.ts @@ -0,0 +1,71 @@ +import { prepare, pull } from '../utils/translations'; +import { runPrepare, runPull } from './translations'; + +jest.mock('../utils/translations'); + +describe('runPrepare', () => { + beforeEach(() => jest.clearAllMocks()); + + it('calls prepare with siteRoot set to cwd', () => { + runPrepare(); + + expect(jest.mocked(prepare)).toHaveBeenCalledWith({ siteRoot: process.cwd() }); + }); +}); + +describe('runPull', () => { + const originalArgv = process.argv; + + beforeEach(() => { + jest.clearAllMocks(); + process.argv = ['node', 'openedx']; + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('calls pull with siteRoot set to cwd', () => { + runPull(); + + expect(jest.mocked(pull)).toHaveBeenCalledWith(expect.objectContaining({ + siteRoot: process.cwd(), + })); + }); + + it('passes shouldPrepare: true by default', () => { + runPull(); + + expect(jest.mocked(pull)).toHaveBeenCalledWith(expect.objectContaining({ + shouldPrepare: true, + })); + }); + + it('passes shouldPrepare: false when --no-prepare is in argv', () => { + process.argv = ['node', 'openedx', '--no-prepare']; + + runPull(); + + expect(jest.mocked(pull)).toHaveBeenCalledWith(expect.objectContaining({ + shouldPrepare: false, + })); + }); + + it('passes atlasOptions when --atlas-options= is in argv', () => { + process.argv = ['node', 'openedx', '--atlas-options=--revision=main']; + + runPull(); + + expect(jest.mocked(pull)).toHaveBeenCalledWith(expect.objectContaining({ + atlasOptions: '--revision=main', + })); + }); + + it('passes atlasOptions as undefined when not in argv', () => { + runPull(); + + expect(jest.mocked(pull)).toHaveBeenCalledWith(expect.objectContaining({ + atlasOptions: undefined, + })); + }); +}); diff --git a/tools/cli/commands/translations.ts b/tools/cli/commands/translations.ts new file mode 100644 index 00000000..6b8dab54 --- /dev/null +++ b/tools/cli/commands/translations.ts @@ -0,0 +1,15 @@ +import child_process from 'child_process'; +import { prepare, pull } from '../utils/translations'; + +export function runPrepare(): void { + prepare({ siteRoot: process.cwd() }); +} + +export function runPull(): void { + pull({ + siteRoot: process.cwd(), + execFileSync: (file, args) => child_process.execFileSync(file, args, { stdio: 'inherit' }), + shouldPrepare: !process.argv.includes('--no-prepare'), + atlasOptions: process.argv.find(a => a.startsWith('--atlas-options='))?.slice('--atlas-options='.length), + }); +} diff --git a/tools/cli/intl-imports.test.ts b/tools/cli/intl-imports.test.ts deleted file mode 100644 index 9436844b..00000000 --- a/tools/cli/intl-imports.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -// Tests for the intl-imports.js command line. - -import path from 'path'; -import { main as realMain } from './intl-imports'; - -const sampleAppDirectory = path.join(__dirname, '../../test-site'); - -// History for `process.stdout.write` mock calls. -const logHistory: { log: string[], latest: string | null } = { - log: [], - latest: '', -}; - -// History for `fs.writeFileSync` mock calls. -const writeFileHistory: { log: { filename: string, content: string }[], latest: { filename: string, content: string } | null } = { - log: [], - latest: null, -}; - -// Mock for process.stdout.write -const log = (text: string) => { - logHistory.latest = text; - logHistory.log.push(text); -}; - -// Mock for fs.writeFileSync -const writeFileSync = (filename: string, content: string) => { - const entry = { filename, content }; - writeFileHistory.latest = entry; - writeFileHistory.log.push(entry); -}; - -// Main with mocked output -const main = (...directories: string[]) => realMain({ - directories, - log, - writeFileSync, - pwd: sampleAppDirectory, -}); - -// Clean up mock histories -afterEach(() => { - logHistory.log = []; - logHistory.latest = null; - writeFileHistory.log = []; - writeFileHistory.latest = null; -}); - -describe('help document', () => { - it('should print help for --help', () => { - main('--help'); - expect(logHistory.latest).toMatch('Script to generate the src/i18n/index.js'); - }); - - it('should print help for -h', () => { - main('-h'); - expect(logHistory.latest).toMatch('Script to generate the src/i18n/index.js'); - }); -}); - -describe('error validation', () => { - it('expects a list of directories', () => { - main(); - expect(logHistory.log.join('\n')).toMatch('Script to generate the src/i18n/index.js'); // Print help error - expect(logHistory.latest).toMatch('Error: A list of directories is required'); // Print error message - }); - - it('expects a directory with a relative path of "src/i18n"', () => { - realMain({ - directories: ['frontend-app-example'], - log, - writeFileSync, - pwd: path.join(__dirname), // __dirname === `scripts` which has no sub-dir `src/i18n` - }); - - expect(logHistory.log.join('\n')).toMatch('Script to generate the src/i18n/index.js'); // Print help on error - expect(logHistory.latest).toMatch('Error: src/i18n directory was not found.'); // Print error message - }); -}); - -describe('generated files', () => { - it('writes a correct src/i18n/index.js file', () => { - main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); - const mainFileActualContent = writeFileHistory.log.find((file) => { - return file.filename.endsWith('src/i18n/index.js'); - })?.content; - - const mainFileExpectedContent = `// This file is generated by the openedx/frontend-base's "intl-import.js" script. -// -// Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update -// the file and use the Micro-frontend i18n pattern in new repositories. -// - -import messagesFromFrontendComponentSinglelang from './messages/frontend-component-singlelang'; -// Skipped import due to missing 'frontend-component-nolangs/index.js' likely due to empty translations.. -// Skipped import due to missing 'frontend-component-emptylangs/index.js' likely due to empty translations.. -import messagesFromFrontendAppSample from './messages/frontend-app-sample'; - -export default [ - messagesFromFrontendComponentSinglelang, - messagesFromFrontendAppSample, -]; -`; - - expect(mainFileActualContent).toEqual(mainFileExpectedContent); - }); - - it('writes a correct frontend-component-singlelang/index.js file', () => { - main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); - const mainFileActualContent = writeFileHistory.log.find(file => file.filename.endsWith('frontend-component-singlelang/index.js'))?.content; - - const singleLangExpectedContent = `// This file is generated by the openedx/frontend-base's "intl-import.js" script. -// -// Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update -// the file and use the Micro-frontend i18n pattern in new repositories. -// - -import messagesOfArLanguage from './ar.json'; - -export default { - 'ar': messagesOfArLanguage, -}; -`; - - expect(mainFileActualContent).toEqual(singleLangExpectedContent); - }); - - it('writes a correct frontend-app-sample/index.js file', () => { - main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); - const mainFileActualContent = writeFileHistory.log.find(file => file.filename.endsWith('frontend-app-sample/index.js'))?.content; - - const singleLangExpectedContent = `// This file is generated by the openedx/frontend-base's "intl-import.js" script. -// -// Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update -// the file and use the Micro-frontend i18n pattern in new repositories. -// - -import messagesOfArLanguage from './ar.json'; -// Note: Skipped empty 'eo.json' messages file. -import messagesOfEs419Language from './es_419.json'; - -export default { - 'ar': messagesOfArLanguage, - 'es-419': messagesOfEs419Language, -}; -`; - - expect(mainFileActualContent).toEqual(singleLangExpectedContent); - }); -}); - -describe('list of generated index.js files', () => { - it('writes only non-empty languages in addition to the main file', () => { - main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); - const writtenFiles = writeFileHistory.log - .map(file => file.filename) - .map(file => path.relative(sampleAppDirectory, file)); - expect(writtenFiles).toEqual([ - 'src/i18n/messages/frontend-component-singlelang/index.js', - 'src/i18n/messages/frontend-app-sample/index.js', - 'src/i18n/index.js', - ]); - }); -}); diff --git a/tools/cli/intl-imports.ts b/tools/cli/intl-imports.ts deleted file mode 100755 index 5100c9b3..00000000 --- a/tools/cli/intl-imports.ts +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env node - -const scriptHelpDocument = ` -NAME - intl-imports.js — Script to generate the src/i18n/index.js file that exports messages from all the languages for Micro-frontends. - -SYNOPSIS - intl-imports.js [DIRECTORY ...] - -DESCRIPTION - This script is intended to run after 'atlas' has pulled the files. - - This expects to run inside a Micro-frontend root directory with the following structure: - - frontend-app-learning $ tree src/i18n/ - src/i18n/ - ├── index.js - └── messages - ├── frontend-app-example - │ ├── ar.json - │ ├── es_419.json - │ └── zh_CN.json - ├── frontend-component-footer - │ ├── ar.json - │ ├── es_419.json - │ └── zh_CN.json - └── frontend-component-header (empty directory) - - - - With the structure above it's expected to run with the following command in Makefile: - - - $ node_modules/.bin/intl-imports.js frontend-component-footer frontend-component-header frontend-app-example - - - It will generate two type of files: - - - Main src/i18n/index.js which overrides the Micro-frontend provided with a sample output of: - - """ - import messagesFromFrontendComponentFooter from './messages/frontend-component-footer'; - // Skipped import due to missing './messages/frontend-component-footer/index.js' likely due to empty translations. - import messagesFromFrontendAppExample from './messages/frontend-app-example'; - - export default [ - messagesFromFrontendComponentFooter, - messagesFromFrontendAppExample, - ]; - """ - - - Each sub-directory has src/i18n/messages/frontend-component-header/index.js which is imported by the main file.: - - """ - import messagesOfArLanguage from './ar.json'; - import messagesOfDeLanguage from './de.json'; - import messagesOfEs419Language from './es_419.json'; - export default { - 'ar': messagesOfArLanguage, - 'de': messagesOfDeLanguage, - 'es-419': messagesOfEs419Language, - }; - """ -`; - -import fs from 'fs'; -import camelCase from 'lodash.camelcase'; -import path from 'path'; - -const loggingPrefix = path.basename(`${__filename}`); // the name of this JS file - -// Header note for generated src/i18n/index.js file -const filesCodeGeneratorNoticeHeader = `// This file is generated by the openedx/frontend-base's "intl-import.js" script. -// -// Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update -// the file and use the Micro-frontend i18n pattern in new repositories. -// -`; - -/** - * Create frontend-app-example/index.js file with proper imports. - * - * @param directory - a directory name containing .json files from Transifex e.g. "frontend-app-example". - * @param log - Mockable process.stdout.write - * @param writeFileSync - Mockable fs.writeFileSync - * @param i18nDir - Path to `src/i18n` directory - * - * @return object - An object containing directory name and whether its "index.js" file was successfully written. - */ -function generateSubdirectoryMessageFile({ - directory, - log, - writeFileSync, - i18nDir, -}: { - directory: string, - log: (message: string) => void, - writeFileSync: (filename: string, content: string) => void, - i18nDir: string, - -}) { - const importLines: string[] = []; - const messagesLines: string[] = []; - const counter = { nonEmptyLanguages: 0 }; - const messagesDir = `${i18nDir}/messages`; // The directory of Micro-frontend i18n messages - - try { - const files = fs.readdirSync(`${messagesDir}/${directory}`, { withFileTypes: true }); - files.sort(); // Sorting ensures a consistent generated `index.js` order of imports cross-platforms. - - const jsonFiles = files.filter(file => file.isFile() && file.name.endsWith('.json')); - - if (!jsonFiles.length) { - log(`${loggingPrefix}: Not creating '${directory}/index.js' because no .json translation files were found.\n`); - return { - directory, - isWritten: false, - }; - } - - jsonFiles.forEach((file) => { - const filename = file.name; - // Gets `fr_CA` from `fr_CA.json` - const languageCode = filename.replace(/\.json$/, ''); - // React-friendly language code fr_CA --> fr-ca - const reactIntlLanguageCode = languageCode.toLowerCase().replace(/_/g, '-'); - // camelCase variable name - const messagesCamelCaseVar = camelCase(`messages_Of_${languageCode}_Language`); - const filePath = `${messagesDir}/${directory}/${filename}`; - - try { - const entries = JSON.parse(fs.readFileSync(filePath, { encoding: 'utf8' })); - - if (!Object.keys(entries).length) { - importLines.push(`// Note: Skipped empty '${filename}' messages file.`); - return; // Skip the language - } - } catch (e) { - importLines.push(`// Error: unable to parse '${filename}' messages file.`); - log(`${loggingPrefix}: NOTICE: Skipping '${directory}/${filename}' due to error: ${e}.\n`); - return; // Skip the language - } - - counter.nonEmptyLanguages += 1; - importLines.push(`import ${messagesCamelCaseVar} from './${filename}';`); - messagesLines.splice(1, 0, ` '${reactIntlLanguageCode}': ${messagesCamelCaseVar},`); - }); - - if (counter.nonEmptyLanguages) { - // See the help message above for sample output. - const messagesFileContent = [ - filesCodeGeneratorNoticeHeader, - importLines.join('\n'), - '\nexport default {', - messagesLines.join('\n'), - '};\n', - ].join('\n'); - - writeFileSync(`${messagesDir}/${directory}/index.js`, messagesFileContent); - return { - directory, - isWritten: true, - }; - } - log(`${loggingPrefix}: Skipping '${directory}' because no languages were found.\n`); - } catch (e) { - log(`${loggingPrefix}: NOTICE: Skipping '${directory}' due to error: ${e}.\n`); - } - - return { - directory, - isWritten: false, - }; -} - -/** - * Create main `src/i18n/index.js` messages import file. - * - * - * @param processedDirectories - List of directories with a boolean flag whether its "index.js" file is written - * The format is "[\{ directory: "frontend-component-example", isWritten: false \}, ...]" - * @param log - Mockable process.stdout.write - * @param writeFileSync - Mockable fs.writeFileSync - * @param i18nDir` - Path to `src/i18n` directory - */ -function generateMainMessagesFile({ - processedDirectories, - log, - writeFileSync, - i18nDir, -}: { - processedDirectories: { directory: string, isWritten: boolean }[], - log: (message: string) => void, - writeFileSync: (filename: string, content: string) => void, - i18nDir: string, -}) { - const importLines: string[] = []; - const exportLines: string[] = []; - - processedDirectories.forEach(processedDirectory => { - const { directory, isWritten } = processedDirectory; - if (isWritten) { - const moduleCamelCaseVariableName = camelCase(`messages_from_${directory}`); - importLines.push(`import ${moduleCamelCaseVariableName} from './messages/${directory}';`); - exportLines.push(` ${moduleCamelCaseVariableName},`); - } else { - const skipMessage = `Skipped import due to missing '${directory}/index.js' likely due to empty translations.`; - importLines.push(`// ${skipMessage}.`); - log(`${loggingPrefix}: ${skipMessage}\n`); - } - }); - - // See the help message above for sample output. - const indexFileContent = [ - filesCodeGeneratorNoticeHeader, - importLines.join('\n'), - '\nexport default [', - exportLines.join('\n'), - '];\n', - ].join('\n'); - - writeFileSync(`${i18nDir}/index.js`, indexFileContent); -} - -/* - * Main function of the file. - */ -export function main({ - directories, - log, - writeFileSync, - pwd, -}: { - directories: string | string[], - log: (message: string) => void, - writeFileSync: (filename: string, content: string) => void, - pwd: string, -}) { - const i18nDir = `${pwd}/src/i18n`; // The Micro-frontend i18n root directory - - if (directories.includes('--help') || directories.includes('-h')) { - log(scriptHelpDocument); - } else if (!directories.length) { - log(scriptHelpDocument); - log(`${loggingPrefix}: Error: A list of directories is required.\n`); - } else if (!fs.existsSync(i18nDir) || !fs.lstatSync(i18nDir).isDirectory()) { - log(scriptHelpDocument); - log(`${loggingPrefix}: Error: src/i18n directory was not found.\n`); - } else { - // If we're here, we know that directories is an array, so cast it as one. - const processedDirectories = (directories as string[]).map((directory: string) => generateSubdirectoryMessageFile({ - directory, - log, - writeFileSync, - i18nDir, - })); - generateMainMessagesFile({ - processedDirectories, - log, - writeFileSync, - i18nDir, - }); - } -} - -if (require.main === module) { - // Run the main() function if called from the command line. - main({ - directories: process.argv.slice(2), - log: text => process.stdout.write(text), - writeFileSync: fs.writeFileSync, - pwd: process.env.PWD ?? '.', - }); -} diff --git a/tools/cli/openedx.ts b/tools/cli/openedx.ts index 35896200..4cb75bdc 100755 --- a/tools/cli/openedx.ts +++ b/tools/cli/openedx.ts @@ -99,6 +99,12 @@ switch (commandName) { case CommandTypes.SERVE: require('./commands/serve'); break; + case CommandTypes.TRANSLATIONS_PULL: + require('./commands/translations').runPull(); + break; + case CommandTypes.TRANSLATIONS_PREPARE: + require('./commands/translations').runPrepare(); + break; case CommandTypes.HELP: printUsage(); break; diff --git a/tools/cli/transifex-utils.ts b/tools/cli/transifex-utils.ts deleted file mode 100755 index 13f9124c..00000000 --- a/tools/cli/transifex-utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node - -import fs from 'fs'; -import glob from 'glob'; -import path from 'path'; - -/* - * See the Makefile for how the required hash file is downloaded from Transifex. - */ - -/* - * Expected input: a directory, possibly containing subdirectories, with .json files. Each .json - * file is an array of translation triplets (id, description, defaultMessage). - * - * - */ -function gatherJson(dir: string) { - const ret: { id: string, description: string, defaultMessage: string }[] = []; - const files = glob.sync(`${dir}/**/*.json`); - - files.forEach((filename) => { - const messages = JSON.parse(fs.readFileSync(filename, { encoding: 'utf8' })); - ret.push(...messages); - }); - return ret; -} - -// the hash file returns ids whose periods are "escaped" (sort of), like this: -// "key": "profile\\.sociallinks\\.social\\.links" -// so our regular messageIds won't match them out of the box -function escapeDots(messageId: string) { - return messageId.replace(/\./g, '\\.'); -} - -const jsonDir = process.argv[2]; -const messageObjects = gatherJson(jsonDir); - -if (messageObjects.length === 0) { - process.exitCode = 1; - throw new Error('Found no messages'); -} - -if (process.argv[3] === '--comments') { // prepare to handle the translator notes - const loggingPrefix = path.basename(`${__filename}`); // the name of this JS file - const bashScriptsPath = ( - process.argv[4] && process.argv[4] === '--v3-scripts-path' - ? './node_modules/@edx/reactifex/bash_scripts' - : './node_modules/reactifex/bash_scripts'); - - const hashFile = `${bashScriptsPath}/hashmap.json`; - process.stdout.write(`${loggingPrefix}: reading hash file ${hashFile}\n`); - const messageInfo = JSON.parse(fs.readFileSync(hashFile, { encoding: 'utf8' })); - - const outputFile = `${bashScriptsPath}/hashed_data.txt`; - process.stdout.write(`${loggingPrefix}: writing to output file ${outputFile}\n`); - fs.writeFileSync(outputFile, ''); - - messageObjects.forEach((message) => { - const transifexFormatId = escapeDots(message.id); - - const info = messageInfo.find((mi: { key: string }) => mi.key === transifexFormatId); - if (info) { - fs.appendFileSync(outputFile, `${info.string_hash}|${message.description}\n`); - } else { - process.stdout.write(`${loggingPrefix}: string ${message.id} does not yet exist on transifex!\n`); - } - }); -} else { - const output: Record = {}; - - messageObjects.forEach((message) => { - output[message.id] = message.defaultMessage; - }); - fs.writeFileSync(process.argv[3], JSON.stringify(output, null, 2)); -} diff --git a/tools/cli/utils/printUsage.ts b/tools/cli/utils/printUsage.ts index a7a0eb41..4f01df7e 100644 --- a/tools/cli/utils/printUsage.ts +++ b/tools/cli/utils/printUsage.ts @@ -38,5 +38,15 @@ export default function printUsage() { console.group(); console.log(`Serves the dist folder with an express server. Used to locally test production assets.\n`); console.groupEnd(); + + console.log(`${chalk.bold('translations:pull')}\n`); + console.group(); + console.log(`Pulls translations for all installed apps using atlas, then runs translations:prepare. Reads atlasTranslations config from package.json. Pass ${chalk.bold('--no-prepare')} to skip the prepare step.\n`); + console.groupEnd(); + + console.log(`${chalk.bold('translations:prepare')}\n`); + console.group(); + console.log(`Generates TypeScript modules from pulled translation JSON files in src/i18n/messages/ and src/i18n/site-messages/.\n`); + console.groupEnd(); console.groupEnd(); } diff --git a/tools/cli/utils/translations/index.ts b/tools/cli/utils/translations/index.ts new file mode 100644 index 00000000..9ca5787d --- /dev/null +++ b/tools/cli/utils/translations/index.ts @@ -0,0 +1,2 @@ +export { prepare } from './prepare'; +export { pull } from './pull'; diff --git a/tools/cli/utils/translations/messagesObject.ts b/tools/cli/utils/translations/messagesObject.ts new file mode 100644 index 00000000..c0333ce0 --- /dev/null +++ b/tools/cli/utils/translations/messagesObject.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import path from 'path'; + +export interface LocaleImport { + localeName: string, // e.g. 'es_419' + filename: string, // e.g. 'es_419.json' +} + +export interface LocaleEntry { + key: string, // e.g. 'es-419' + varName: string, // e.g. 'es_419' +} + +export interface MessagesObject { + imports: LocaleImport[], + entries: LocaleEntry[], +} + +/** + * Reads all locale JSON files in a directory and returns the data needed to + * render a messages object ({ 'ar': ar, 'es-419': es_419 }). + * Returns null if no non-empty JSON files are found. + */ +export function generateMessagesObject(dirPath: string): MessagesObject | null { + const dirEntries = fs.readdirSync(dirPath, { withFileTypes: true }); + const jsonFiles = dirEntries + .filter(e => e.isFile() && e.name.endsWith('.json')) + .map(e => e.name); + + const imports: LocaleImport[] = []; + const entries: LocaleEntry[] = []; + + for (const filename of jsonFiles) { + const localeName = filename.replace(/\.json$/, ''); + const key = localeName.toLowerCase().replace(/_/g, '-'); + const filePath = path.join(dirPath, filename); + + try { + const parsed = JSON.parse(fs.readFileSync(filePath, { encoding: 'utf8' })); + if (!Object.keys(parsed).length) { + continue; // skip empty files + } + } catch { + continue; + } + + imports.push({ localeName, filename }); + entries.push({ key, varName: localeName }); + } + + if (!imports.length) { + return null; + } + + return { imports, entries }; +} + +export function renderImportLine({ localeName, filename }: LocaleImport): string { + return `import ${localeName} from './${filename}';`; +} + +export function renderImportsBlock(messagesObject: MessagesObject): string { + return messagesObject.imports.map(renderImportLine).join('\n'); +} + +export function renderExportLine({ key, varName }: LocaleEntry): string { + return ` '${key}': ${varName},`; +} + +export function renderExportBlock(messagesObject: MessagesObject): string { + const lines = messagesObject.entries.map(renderExportLine).join('\n'); + return `export default {\n${lines}\n};\n`; +} + +/** + * Renders a MessagesObject and writes it to an index.ts file in the given directory. + */ +export function writeMessagesObjectToFile( + dirPath: string, + messagesObject: MessagesObject, +): void { + const content = `${renderImportsBlock(messagesObject)}\n\n${renderExportBlock(messagesObject)}`; + fs.writeFileSync(path.join(dirPath, 'index.ts'), content); +} diff --git a/tools/cli/utils/translations/prepare.test.ts b/tools/cli/utils/translations/prepare.test.ts new file mode 100644 index 00000000..d2ece1ec --- /dev/null +++ b/tools/cli/utils/translations/prepare.test.ts @@ -0,0 +1,232 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { prepare } from './prepare'; + +type MessagesStructure = Record>; + +function setupI18nMessages(baseDir: string, structure: MessagesStructure): void { + for (const [packagePath, files] of Object.entries(structure)) { + const pkgDir = path.join(baseDir, 'src', 'i18n', 'messages', packagePath); + fs.mkdirSync(pkgDir, { recursive: true }); + for (const [filename, content] of Object.entries(files)) { + fs.writeFileSync(path.join(pkgDir, filename), JSON.stringify(content), { encoding: 'utf8' }); + } + } +} + +function setupSiteMessages(baseDir: string, files: Record): void { + const dir = path.join(baseDir, 'src', 'i18n', 'site-messages'); + fs.mkdirSync(dir, { recursive: true }); + for (const [filename, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, filename), JSON.stringify(content), { encoding: 'utf8' }); + } +} + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, { encoding: 'utf8' }); +} + +const tmpPrefix = path.join(os.tmpdir(), 'translations-test-'); + +describe('prepare', () => { + it('generates per-package import and export blocks properly', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { + 'fr.json': { hello: 'bonjour', world: 'monde' }, + }, + }); + + prepare({ siteRoot: tmp.path }); + + const authnIndex = readFile(path.join(tmp.path, 'src', 'i18n', 'messages', '@openedx', 'frontend-app-authn', 'index.ts')); + expect(authnIndex).toBe( + "import fr from './fr.json';\n" + + '\n' + + 'export default {\n' + + " 'fr': fr,\n" + + '};\n', + ); + }); + + it('generates messages.ts import and export blocks properly', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { + 'fr.json': { hello: 'bonjour', world: 'monde' }, + }, + }); + + prepare({ siteRoot: tmp.path }); + + const messagesContent = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')); + expect(messagesContent).toBe( + "import frontendAppAuthnMessages from './messages/@openedx/frontend-app-authn';\n" + + '\n' + + 'export default [\n' + + ' frontendAppAuthnMessages,\n' + + '];\n', + ); + }); + + it('generates per-package index.ts and messages.ts for a basic structure', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { + 'fr.json': { hello: 'bonjour', world: 'monde' }, + 'es_419.json': { hello: 'hola', world: 'mundo' }, + }, + }); + + prepare({ siteRoot: tmp.path }); + + const authnIndex = readFile(path.join(tmp.path, 'src', 'i18n', 'messages', '@openedx', 'frontend-app-authn', 'index.ts')); + // import block + expect(authnIndex).toContain("import fr from './fr.json';"); + expect(authnIndex).toContain("import es_419 from './es_419.json';"); + // export block + expect(authnIndex).toContain("'fr': fr,"); + expect(authnIndex).toContain("'es-419': es_419,"); + + const messagesContent = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')); + // import block + expect(messagesContent).toContain("import frontendAppAuthnMessages from './messages/@openedx/frontend-app-authn';"); + // export block + expect(messagesContent).toContain('frontendAppAuthnMessages,'); + }); + + it('skips empty JSON files when generating index.ts', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { + 'fr.json': { hello: 'bonjour', world: 'monde' }, + 'es_419.json': {}, // empty — should be skipped + }, + }); + + prepare({ siteRoot: tmp.path }); + + const authnIndex = readFile(path.join(tmp.path, 'src', 'i18n', 'messages', '@openedx', 'frontend-app-authn', 'index.ts')); + expect(authnIndex).toContain('fr'); + expect(authnIndex).not.toContain('es_419'); + }); + + it('does not include a package that has no JSON files', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { 'fr.json': { hello: 'bonjour' } }, + '@openedx/frontend-app-learning': {}, + }); + + prepare({ siteRoot: tmp.path }); + + const messagesContent = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')); + expect(messagesContent).toContain('frontendAppAuthnMessages'); + expect(messagesContent).not.toContain('frontendAppLearningMessages'); + }); + + it('normalizes locale codes: es_419 filename uses es_419 var but es-419 key', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { + 'es_419.json': { hello: 'hola', world: 'mundo' }, + }, + }); + + prepare({ siteRoot: tmp.path }); + + const authnIndex = readFile(path.join(tmp.path, 'src', 'i18n', 'messages', '@openedx', 'frontend-app-authn', 'index.ts')); + expect(authnIndex).toContain("'es-419': es_419,"); + expect(authnIndex).not.toContain("'es_419':"); + }); + + it('generates messages.ts with empty array when no messages dir and no site-messages exist', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + + prepare({ siteRoot: tmp.path }); + + const messagesContent = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')); + expect(messagesContent).toBe('export default [];\n'); + }); + + it('generates messages.ts with empty array when messages dir exists but has no packages', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + fs.mkdirSync(path.join(tmp.path, 'src', 'i18n', 'messages'), { recursive: true }); + + prepare({ siteRoot: tmp.path }); + + const messagesContent = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')); + expect(messagesContent).toBe('export default [];\n'); + }); + + it('includes site-messages last when both messages and site-messages are present', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { 'ar.json': { key: 'val' } }, + }); + setupSiteMessages(tmp.path, { 'ar.json': { key: 'override' } }); + + prepare({ siteRoot: tmp.path }); + + const lines = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')).trimEnd().split('\n'); + expect(lines.at(-1)).toBe('];'); + expect(lines.at(-2)).toBe(' siteMessages,'); + }); + + it('generates messages.ts with only siteMessages when no messages dir exists', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupSiteMessages(tmp.path, { 'ar.json': { key: 'val' } }); + + prepare({ siteRoot: tmp.path }); + + const content = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')); + expect(content).toContain("import siteMessages from './site-messages';"); + expect(content).not.toContain('./messages/'); + }); + + it('does not include site-messages when it has no JSON files', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { 'ar.json': { key: 'val' } }, + }); + const siteMessagesDir = path.join(tmp.path, 'src', 'i18n', 'site-messages'); + fs.mkdirSync(siteMessagesDir, { recursive: true }); + fs.writeFileSync(path.join(siteMessagesDir, 'readme.txt'), 'ignore me', { encoding: 'utf8' }); + + prepare({ siteRoot: tmp.path }); + + const content = readFile(path.join(tmp.path, 'src', 'i18n', 'messages.ts')); + expect(content).not.toContain('siteMessages'); + }); + + it('never writes to src/i18n/index.ts', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { 'ar.json': { key: 'val' } }, + }); + setupSiteMessages(tmp.path, { 'ar.json': { key: 'override' } }); + + prepare({ siteRoot: tmp.path }); + + expect(fs.existsSync(path.join(tmp.path, 'src', 'i18n', 'index.ts'))).toBe(false); + }); + + it('ignores non-JSON files in package directories', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + setupI18nMessages(tmp.path, { + '@openedx/frontend-app-authn': { 'ar.json': { key: 'val' } }, + }); + fs.writeFileSync( + path.join(tmp.path, 'src', 'i18n', 'messages', '@openedx', 'frontend-app-authn', 'readme.txt'), + 'should be ignored', + { encoding: 'utf8' }, + ); + + prepare({ siteRoot: tmp.path }); + + const authnIndex = readFile(path.join(tmp.path, 'src', 'i18n', 'messages', '@openedx', 'frontend-app-authn', 'index.ts')); + expect(authnIndex).not.toContain('readme'); + expect(authnIndex).toContain('ar'); + }); +}); diff --git a/tools/cli/utils/translations/prepare.ts b/tools/cli/utils/translations/prepare.ts new file mode 100644 index 00000000..1829086e --- /dev/null +++ b/tools/cli/utils/translations/prepare.ts @@ -0,0 +1,111 @@ +import fs from 'fs'; +import camelCase from 'lodash.camelcase'; +import path from 'path'; +import { generateMessagesObject, writeMessagesObjectToFile, type MessagesObject } from './messagesObject'; + +function packageVarName(packagePath: string): string { + const name = packagePath.split('/').pop()!; + return `${camelCase(name)}Messages`; +} + +function getPackageMessages(messagesDir: string, packagePath: string): MessagesObject | null { + return generateMessagesObject(path.join(messagesDir, packagePath)); +} + +function getPackagePaths(messagesDir: string): string[] { + const directoryEntries = fs.readdirSync(messagesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()); + + const packagePaths: string[] = []; + for (const entry of directoryEntries) { + const directory = fs.readdirSync(path.join(messagesDir, entry.name), { withFileTypes: true }); + if (!directory.some(e => e.isDirectory())) { + // no subdirectories, this is a package dir + packagePaths.push(entry.name); + continue; + } + + // this is a scope dir, subdirs are packages + const packageDirs = directory.filter(e => e.isDirectory()); + for (const pkg of packageDirs) { + packagePaths.push(`${entry.name}/${pkg.name}`); + } + } + + return packagePaths; +} + +function getAllPackageMessages(messagesDir: string): Map { + if (!fs.existsSync(messagesDir)) { + // directory doesn't exist + return new Map(); + } + + const directory = fs.readdirSync(messagesDir, { withFileTypes: true }); + if (!directory.some(e => e.isDirectory())) { + // directory exists but has no subdirectories + return new Map(); + } + + const packagePaths = getPackagePaths(messagesDir); + const result = new Map(); + for (const packagePath of packagePaths) { + const messagesObject = getPackageMessages(messagesDir, packagePath); + if (messagesObject) { + result.set(packagePath, messagesObject); + } + } + return result; +} + +export function prepareAllPackageMessages(messagesDir: string): [string[], string[]] { + const packageMessages = getAllPackageMessages(messagesDir); + + const importLines: string[] = []; + const exportItems: string[] = []; + + for (const [packagePath, messagesObject] of packageMessages) { + writeMessagesObjectToFile(path.join(messagesDir, packagePath), messagesObject); + const varName = packageVarName(packagePath); + importLines.push(`import ${varName} from './messages/${packagePath}';`); + exportItems.push(` ${varName},`); + } + + return [importLines, exportItems]; +} + +export function prepareSiteMessages(siteMessagesDir: string): [string[], string[]] { + const siteMessages = fs.existsSync(siteMessagesDir) + ? generateMessagesObject(siteMessagesDir) + : null; + + if (!siteMessages) { + return [[], []]; + } + + writeMessagesObjectToFile(siteMessagesDir, siteMessages); + return [ + [`import siteMessages from './site-messages';`], + [` siteMessages,`], + ]; +} + +export function prepare({ siteRoot }: { siteRoot: string }): void { + const i18nDir = path.join(siteRoot, 'src', 'i18n'); + const messagesDir = path.join(i18nDir, 'messages'); + const siteMessagesDir = path.join(i18nDir, 'site-messages'); + + const [packageImportLines, packageExportItems] = prepareAllPackageMessages(messagesDir); + const [siteImportLines, siteExportItems] = prepareSiteMessages(siteMessagesDir); + + const importLines = [...packageImportLines, ...siteImportLines]; + const exportItems = [...packageExportItems, ...siteExportItems]; + + const hasMessages = importLines.length > 0 && exportItems.length > 0; + const messagesContent = hasMessages + ? `${importLines.join('\n')}\n\nexport default [\n${exportItems.join('\n')}\n];\n` + : 'export default [];\n'; + + fs.mkdirSync(i18nDir, { recursive: true }); + fs.writeFileSync(path.join(i18nDir, 'messages.ts'), messagesContent); +} diff --git a/tools/cli/utils/translations/pull.test.ts b/tools/cli/utils/translations/pull.test.ts new file mode 100644 index 00000000..ceb8cd6a --- /dev/null +++ b/tools/cli/utils/translations/pull.test.ts @@ -0,0 +1,341 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { prepare } from './prepare'; +import { pull } from './pull'; + +jest.mock('./prepare'); + +interface AtlasTranslations { + path?: string, + dependencies?: string[], +} + +function createPackage(baseDir: string, name: string, atlasTranslations?: AtlasTranslations): void { + const pkgDir = path.join(baseDir, 'node_modules', name); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name, ...(atlasTranslations !== undefined ? { atlasTranslations } : {}) }), + { encoding: 'utf8' }, + ); +} + +function createSite(baseDir: string, atlasTranslations?: AtlasTranslations): void { + fs.writeFileSync( + path.join(baseDir, 'package.json'), + JSON.stringify({ name: 'test-site', ...(atlasTranslations !== undefined ? { atlasTranslations } : {}) }), + { encoding: 'utf8' }, + ); +} + +const tmpPrefix = path.join(os.tmpdir(), 'translations-test-'); + +describe('pull', () => { + let mockExecFileSync: jest.Mock; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockExecFileSync = jest.fn(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('calls atlas pull', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/frontend-app-authn'] }); + createPackage(tmp.path, '@openedx/frontend-app-authn', { + path: 'translations/frontend-app-authn/src/i18n', + }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining(['pull'])); + }); + + it('does not call atlas pull when dependencies list is empty', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: [] }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('does not call atlas pull when all dependencies fail to resolve', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/missing'] }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('calls atlas pull with one FROM:TO mapping', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/frontend-app-authn'] }); + createPackage(tmp.path, '@openedx/frontend-app-authn', { + path: 'translations/frontend-app-authn/src/i18n', + }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'atlas', expect.arrayContaining(['translations/frontend-app-authn/src/i18n:src/i18n/messages/@openedx/frontend-app-authn']), + ); + }); + + it('includes atlasOptions in the atlas pull command when provided', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/frontend-app-authn'] }); + createPackage(tmp.path, '@openedx/frontend-app-authn', { + path: 'translations/frontend-app-authn/src/i18n', + }); + + const atlasOptions = '--repository=https://github.com/example/translations --revision=main'; + pull({ + siteRoot: tmp.path, + execFileSync: mockExecFileSync, + shouldPrepare: false, + atlasOptions, + }); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'atlas', expect.arrayContaining(['--repository=https://github.com/example/translations', '--revision=main']), + ); + }); + + it('collects transitive dependency paths', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { + path: 'translations/authn/src/i18n', + dependencies: ['@openedx/frontend-base'], + }); + createPackage(tmp.path, '@openedx/frontend-base', { + path: 'translations/frontend-base/src/i18n', + dependencies: ['@openedx/paragon'], + }); + createPackage(tmp.path, '@openedx/paragon', { path: 'translations/paragon/src/i18n' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining(['translations/authn/src/i18n:src/i18n/messages/@openedx/authn'])); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining(['translations/frontend-base/src/i18n:src/i18n/messages/@openedx/frontend-base'])); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining(['translations/paragon/src/i18n:src/i18n/messages/@openedx/paragon'])); + }); + + it('deduplicates shared dependencies so each appears only once', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn', '@openedx/learning'] }); + createPackage(tmp.path, '@openedx/authn', { + path: 'translations/authn/src/i18n', + dependencies: ['@openedx/paragon'], + }); + createPackage(tmp.path, '@openedx/learning', { + path: 'translations/learning/src/i18n', + dependencies: ['@openedx/paragon'], + }); + createPackage(tmp.path, '@openedx/paragon', { path: 'translations/paragon/src/i18n' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + const atlasArgs = mockExecFileSync.mock.calls[0][1] as string[]; + const occurrences = atlasArgs.filter(a => a === 'translations/paragon/src/i18n:src/i18n/messages/@openedx/paragon').length; + expect(occurrences).toBe(1); + }); + + it('detects circular dependencies, warns, and still collects both paths', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { + path: 'translations/authn/src/i18n', + dependencies: ['@openedx/paragon'], + }); + createPackage(tmp.path, '@openedx/paragon', { + path: 'translations/paragon/src/i18n', + dependencies: ['@openedx/authn'], // circular + }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(warnSpy).toHaveBeenCalledWith('translations:pull: Circular dependency detected: test-site → @openedx/authn → @openedx/paragon → @openedx/authn, skipping.'); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining('@openedx/authn')])); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining('@openedx/paragon')])); + }); + + it('uses the full scoped package name as the TO alias', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/frontend-app-authn'] }); + createPackage(tmp.path, '@openedx/frontend-app-authn', { + path: 'translations/authn/src/i18n', + }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining(':src/i18n/messages/@openedx/frontend-app-authn')])); + expect(mockExecFileSync).not.toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining(':src/i18n/messages/frontend-app-authn')])); + }); + + it('does not touch the site-messages directory', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + + const siteMessagesDir = path.join(tmp.path, 'src', 'i18n', 'site-messages'); + fs.mkdirSync(siteMessagesDir, { recursive: true }); + const arJson = path.join(siteMessagesDir, 'ar.json'); + fs.writeFileSync(arJson, '{"key":"value"}', { encoding: 'utf8' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(fs.readdirSync(siteMessagesDir)).toEqual(['ar.json']); + expect(fs.readFileSync(arJson, { encoding: 'utf8' })).toBe('{"key":"value"}'); + }); + + it('clears messages/ directory before pulling', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + + const staleFile = path.join(tmp.path, 'src', 'i18n', 'messages', 'old-package', 'ar.json'); + fs.mkdirSync(path.dirname(staleFile), { recursive: true }); + fs.writeFileSync(staleFile, '{"key":"stale"}', { encoding: 'utf8' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(fs.existsSync(staleFile)).toBe(false); + }); + + it('replaces stale translation file with newly pulled content', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + + const translationFile = path.join(tmp.path, 'src', 'i18n', 'messages', '@openedx', 'authn', 'ar.json'); + fs.mkdirSync(path.dirname(translationFile), { recursive: true }); + fs.writeFileSync(translationFile, '{"key":"stale"}', { encoding: 'utf8' }); + + mockExecFileSync.mockImplementation(() => { + // wx flag fails if the file already exists, so this throws unless clearing happened first + fs.mkdirSync(path.dirname(translationFile), { recursive: true }); + fs.writeFileSync(translationFile, '{"key":"new"}', { encoding: 'utf8', flag: 'wx' }); + }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(fs.readFileSync(translationFile, { encoding: 'utf8' })).toBe('{"key":"new"}'); + }); + + it('runs prepare by default after pulling', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + + expect(jest.mocked(prepare)).toHaveBeenCalledWith({ siteRoot: tmp.path }); + }); + + it('skips prepare when shouldPrepare is false', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(jest.mocked(prepare)).not.toHaveBeenCalled(); + }); + + it('warns and continues when a package is missing from node_modules', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn', '@openedx/missing'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(warnSpy).toHaveBeenCalledWith('translations:pull: Package @openedx/missing not found in node_modules, skipping.'); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining('@openedx/authn')])); + expect(mockExecFileSync).not.toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining('@openedx/missing')])); + }); + + it('warns and continues when a dependency has no atlasTranslations config', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn', '@openedx/no-translations'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + createPackage(tmp.path, '@openedx/no-translations'); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(warnSpy).toHaveBeenCalledWith('translations:pull: No atlasTranslations config in @openedx/no-translations, skipping.'); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining('@openedx/authn')])); + expect(mockExecFileSync).not.toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining('@openedx/no-translations')])); + }); + + it('pulls translations for the top-level package when atlasTranslations.path is set', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { path: 'translations/test-site/src/i18n' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'atlas', expect.arrayContaining(['translations/test-site/src/i18n:src/i18n/messages/test-site']), + ); + }); + + it('throws when atlasTranslations.path is set but package.json has no name field', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + fs.writeFileSync( + path.join(tmp.path, 'package.json'), + JSON.stringify({ atlasTranslations: { path: 'translations/test-site/src/i18n' } }), + { encoding: 'utf8' }, + ); + + expect(() => { + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + }).toThrow('atlasTranslations.path is set'); + }); + + it('throws an informative error when the site has no atlasTranslations field', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path); + + expect(() => { + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: true }); + }).toThrow('No atlasTranslations field in'); + }); + + it('surfaces atlas command failures', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/authn'] }); + createPackage(tmp.path, '@openedx/authn', { path: 'translations/authn/src/i18n' }); + + const failingExecFileSync = () => { + throw new Error('atlas exited with code 1'); + }; + + expect(() => { + pull({ siteRoot: tmp.path, execFileSync: failingExecFileSync, shouldPrepare: false }); + }).toThrow('atlas exited with code 1'); + }); + + it('follows dependencies of a package with no path without logging a warning', () => { + using tmp = fs.mkdtempDisposableSync(tmpPrefix); + createSite(tmp.path, { dependencies: ['@openedx/meta-package'] }); + createPackage(tmp.path, '@openedx/meta-package', { + dependencies: ['@openedx/paragon'], + }); + createPackage(tmp.path, '@openedx/paragon', { path: 'translations/paragon/src/i18n' }); + + pull({ siteRoot: tmp.path, execFileSync: mockExecFileSync, shouldPrepare: false }); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith('atlas', expect.arrayContaining(['translations/paragon/src/i18n:src/i18n/messages/@openedx/paragon'])); + expect(mockExecFileSync).not.toHaveBeenCalledWith('atlas', expect.arrayContaining([expect.stringContaining('@openedx/meta-package')])); + }); +}); diff --git a/tools/cli/utils/translations/pull.ts b/tools/cli/utils/translations/pull.ts new file mode 100644 index 00000000..b4a6e301 --- /dev/null +++ b/tools/cli/utils/translations/pull.ts @@ -0,0 +1,151 @@ +import fs from 'fs'; +import path from 'path'; +import { prepare } from './prepare'; + +interface PackageTranslationsConfig { + name?: string, + atlasTranslations?: { + path?: string, + dependencies?: string[], + }, +} + +interface ResolvedMapping { + from: string, // atlas FROM path + to: string, // full package name (TO) +} + +function validateSiteTranslationsConfig(siteRoot: string): void { + const pkgJsonPath = path.join(siteRoot, 'package.json'); + let config: PackageTranslationsConfig; + try { + config = JSON.parse(fs.readFileSync(pkgJsonPath, { encoding: 'utf8' })); + } catch { + throw new Error(`translations:pull: Could not read ${pkgJsonPath}`); + } + + if (!config.atlasTranslations) { + throw new Error( + `translations:pull: No atlasTranslations field in ${pkgJsonPath}. ` + + 'Add an atlasTranslations config to enable translation pulling.', + ); + } + + if (config.atlasTranslations.path && !config.name) { + throw new Error( + `translations:pull: atlasTranslations.path is set in ${pkgJsonPath} but there is no name field. ` + + 'A name field is required to identify this package as a translation source.', + ); + } +} + +function readTranslationsConfig(pkgJsonPath: string, nodeModulesBase: string): { + packageName: string, + config: PackageTranslationsConfig, +} | null { + const packageName = path.relative(nodeModulesBase, path.dirname(pkgJsonPath)); + + if (!fs.existsSync(pkgJsonPath)) { + console.warn(`translations:pull: Package ${packageName} not found in node_modules, skipping.`); + return null; + } + + let config: PackageTranslationsConfig; + try { + config = JSON.parse(fs.readFileSync(pkgJsonPath, { encoding: 'utf8' })); + } catch { + console.warn(`translations:pull: Error reading package.json for ${packageName}, skipping.`); + return null; + } + + const resolvedPackageName = config.name ?? packageName; + + if (!config.atlasTranslations) { + console.warn(`translations:pull: No atlasTranslations config in ${resolvedPackageName}, skipping.`); + return null; + } + + return { packageName: resolvedPackageName, config }; +} + +function resolveTranslationMappings(pkgJsonPath: string, nodeModulesBase: string): ResolvedMapping[] { + const visited = new Set(); + + function resolve(pkgJsonPath: string, ancestors: string[]): ResolvedMapping[] { + const read = readTranslationsConfig(pkgJsonPath, nodeModulesBase); + if (!read) return []; + + const { packageName, config } = read; + + if (ancestors.includes(packageName)) { + console.warn(`translations:pull: Circular dependency detected: ${[...ancestors, packageName].join(' → ')}, skipping.`); + return []; + } + + if (visited.has(packageName)) { + return []; + } + + visited.add(packageName); + const results: ResolvedMapping[] = []; + + const mapping: ResolvedMapping | null = config.atlasTranslations?.path + ? { from: config.atlasTranslations.path, to: packageName } + : null; + + if (mapping) { + results.push(mapping); + } + + const dependencies: string[] = config.atlasTranslations?.dependencies ?? []; + for (const dep of dependencies) { + results.push(...resolve(path.join(nodeModulesBase, dep, 'package.json'), [...ancestors, packageName])); + } + + return results; + } + + return resolve(pkgJsonPath, []); +} + +function clearMessages(messagesDir: string): void { + if (fs.existsSync(messagesDir)) { + fs.rmSync(messagesDir, { recursive: true }); + } + fs.mkdirSync(messagesDir, { recursive: true }); +} + +export function pull({ + siteRoot, + execFileSync, + shouldPrepare, + atlasOptions = '', +}: { + siteRoot: string, + execFileSync: (file: string, args: string[]) => void, + shouldPrepare: boolean, + atlasOptions?: string, +}): void { + validateSiteTranslationsConfig(siteRoot); + + // only interact with messages dir, not site-messages + const messagesDir = path.join(siteRoot, 'src', 'i18n', 'messages'); + const nodeModulesBase = path.join(siteRoot, 'node_modules'); + + clearMessages(messagesDir); + + const mappings = resolveTranslationMappings( + path.join(siteRoot, 'package.json'), + nodeModulesBase, + ); + + if (!mappings.length) { + if (shouldPrepare) prepare({ siteRoot }); + return; + } + + const atlasOptionsArgs = atlasOptions.trim().split(/\s+/).filter(Boolean); + const atlasMappingArgs = mappings.map(m => `${m.from}:src/i18n/messages/${m.to}`); + execFileSync('atlas', ['pull', ...atlasOptionsArgs, ...atlasMappingArgs]); + if (shouldPrepare) prepare({ siteRoot }); +} diff --git a/tools/tsconfig.build.json b/tools/tsconfig.build.json index 302cc0bb..925710c4 100644 --- a/tools/tsconfig.build.json +++ b/tools/tsconfig.build.json @@ -5,5 +5,9 @@ "noEmit": false, "composite": true, "tsBuildInfoFile": "../.tsbuildinfo.tools" - } + }, + "exclude": [ + "**/*.test.ts", + "**/*.test.js" + ] } diff --git a/tools/tsconfig.json b/tools/tsconfig.json index f459c7cf..a60eee1f 100644 --- a/tools/tsconfig.json +++ b/tools/tsconfig.json @@ -1,9 +1,10 @@ { - "extends": "@tsconfig/node20/tsconfig.json", + "extends": "@tsconfig/node24/tsconfig.json", "compilerOptions": { "allowJs": true, "resolveJsonModule": true, - "noEmit": true + "noEmit": true, + "types": ["jest"] }, "include": [ "babel/**/*", diff --git a/tools/types.ts b/tools/types.ts index 92a69902..c58650f9 100644 --- a/tools/types.ts +++ b/tools/types.ts @@ -15,5 +15,7 @@ export enum CommandTypes { DEV = 'dev', FORMAT_JS = 'formatjs', SERVE = 'serve', + TRANSLATIONS_PULL = 'translations:pull', + TRANSLATIONS_PREPARE = 'translations:prepare', HELP = 'help', } diff --git a/tools/webpack/common-config/index.ts b/tools/webpack/common-config/index.ts index 48217e2b..e2992236 100644 --- a/tools/webpack/common-config/index.ts +++ b/tools/webpack/common-config/index.ts @@ -4,3 +4,4 @@ export { default as getImageMinimizer } from './all/getImageMinimizer'; export { default as getStylesheetRule } from './all/getStylesheetRule'; export { default as getDevServer } from './dev/getDevServer'; export { default as getHtmlWebpackPlugin } from './site/getHtmlWebpackPlugin'; +export { default as getI18nMessagesFallbackPlugin } from './site/getI18nMessagesFallbackPlugin'; diff --git a/tools/webpack/common-config/site/getI18nMessagesFallbackPlugin/emptyI18n.ts b/tools/webpack/common-config/site/getI18nMessagesFallbackPlugin/emptyI18n.ts new file mode 100644 index 00000000..d6d1738d --- /dev/null +++ b/tools/webpack/common-config/site/getI18nMessagesFallbackPlugin/emptyI18n.ts @@ -0,0 +1 @@ +export default []; diff --git a/tools/webpack/common-config/site/getI18nMessagesFallbackPlugin/index.ts b/tools/webpack/common-config/site/getI18nMessagesFallbackPlugin/index.ts new file mode 100644 index 00000000..abacacf0 --- /dev/null +++ b/tools/webpack/common-config/site/getI18nMessagesFallbackPlugin/index.ts @@ -0,0 +1,23 @@ +import fs from 'fs'; +import path from 'path'; +import webpack from 'webpack'; + +// When messages.ts hasn't been generated yet (i.e. translations:pull hasn't been run), +// replace the ./messages import in src/i18n/index.ts with an empty fallback so webpack +// doesn't error on the missing file. +export default function getI18nMessagesFallbackPlugin(): webpack.NormalModuleReplacementPlugin { + return new webpack.NormalModuleReplacementPlugin( + // Matches the raw import string `./messages` — anchored to avoid partial matches. + /^\.\/messages$/, + (resource) => { + // Only apply to imports from src/i18n/ — exact match to avoid false positives. + if (resource.context !== path.resolve(process.cwd(), 'src', 'i18n')) return; + + // If messages.ts exists, let webpack resolve it normally. + if (fs.existsSync(path.join(resource.context, 'messages.ts'))) return; + + // File is missing — substitute the empty fallback module. + resource.request = require.resolve('./emptyI18n'); + }, + ); +} diff --git a/tools/webpack/utils/getResolvedSiteI18nPath.ts b/tools/webpack/utils/getResolvedSiteI18nPath.ts new file mode 100644 index 00000000..4d3f2b02 --- /dev/null +++ b/tools/webpack/utils/getResolvedSiteI18nPath.ts @@ -0,0 +1,23 @@ +import fs from 'fs'; +import path from 'path'; + +export default function getResolvedSiteI18nPath(defaultDirname: string) { + const siteI18nPath = process.env.SITE_I18N_PATH; + + if (siteI18nPath !== undefined) { + const absolutePath = path.resolve(process.cwd(), siteI18nPath); + if (fs.existsSync(absolutePath)) { + return absolutePath; + } + console.error(`Invalid site i18n path (${siteI18nPath}) specified as an environment variable. Aborting.`); + process.exit(1); + } + + const defaultPath = path.resolve(process.cwd(), defaultDirname); + if (fs.existsSync(defaultPath)) { + return defaultPath; + } + + console.error(`Default site i18n directory (${defaultPath}) does not exist. Aborting.`); + process.exit(1); +} diff --git a/tools/webpack/webpack.config.build.ts b/tools/webpack/webpack.config.build.ts index f22ec997..da71e47e 100644 --- a/tools/webpack/webpack.config.build.ts +++ b/tools/webpack/webpack.config.build.ts @@ -10,14 +10,17 @@ import { getCodeRules, getFileLoaderRules, getHtmlWebpackPlugin, + getI18nMessagesFallbackPlugin, getImageMinimizer, getStylesheetRule } from './common-config'; import getPublicPath from './utils/getPublicPath'; import getResolvedSiteConfigPath from './utils/getResolvedSiteConfigPath'; +import getResolvedSiteI18nPath from './utils/getResolvedSiteI18nPath'; const resolvedSiteConfigPath = getResolvedSiteConfigPath('site.config.build.tsx'); +const resolvedSiteI18nPath = getResolvedSiteI18nPath('src/i18n'); const config: Configuration = { mode: 'production', @@ -34,6 +37,7 @@ const config: Configuration = { resolve: { alias: { 'site.config': resolvedSiteConfigPath, + 'site.i18n': resolvedSiteI18nPath, }, plugins: [ new TsconfigPathsPlugin({ @@ -70,6 +74,7 @@ const config: Configuration = { filename: '[name].[chunkhash].css', }), getHtmlWebpackPlugin(), + getI18nMessagesFallbackPlugin(), new ForkTsCheckerWebpackPlugin(), new BundleAnalyzerPlugin({ analyzerMode: 'static', diff --git a/tools/webpack/webpack.config.dev.shell.ts b/tools/webpack/webpack.config.dev.shell.ts index e070d00f..943a7874 100644 --- a/tools/webpack/webpack.config.dev.shell.ts +++ b/tools/webpack/webpack.config.dev.shell.ts @@ -17,8 +17,10 @@ import { import HtmlWebpackPlugin from 'html-webpack-plugin'; import getPublicPath from './utils/getPublicPath'; import getResolvedSiteConfigPath from './utils/getResolvedSiteConfigPath'; +import getResolvedSiteI18nPath from './utils/getResolvedSiteI18nPath'; const resolvedSiteConfigPath = getResolvedSiteConfigPath('shell/site.config.dev.tsx'); +const resolvedSiteI18nPath = getResolvedSiteI18nPath('test-site/src/i18n'); const config: Configuration = { entry: { @@ -31,6 +33,7 @@ const config: Configuration = { resolve: { alias: { 'site.config': resolvedSiteConfigPath, + 'site.i18n': resolvedSiteI18nPath, }, extensions: ['.js', '.jsx', '.ts', '.tsx'], }, diff --git a/tools/webpack/webpack.config.dev.ts b/tools/webpack/webpack.config.dev.ts index c1b74bb6..fd912c26 100644 --- a/tools/webpack/webpack.config.dev.ts +++ b/tools/webpack/webpack.config.dev.ts @@ -11,14 +11,17 @@ import { getDevServer, getFileLoaderRules, getHtmlWebpackPlugin, + getI18nMessagesFallbackPlugin, getImageMinimizer, getStylesheetRule } from './common-config'; import getPublicPath from './utils/getPublicPath'; import getResolvedSiteConfigPath from './utils/getResolvedSiteConfigPath'; +import getResolvedSiteI18nPath from './utils/getResolvedSiteI18nPath'; const resolvedSiteConfigPath = getResolvedSiteConfigPath('site.config.dev.tsx'); +const resolvedSiteI18nPath = getResolvedSiteI18nPath('src/i18n'); const config: Configuration = { entry: { @@ -31,6 +34,7 @@ const config: Configuration = { resolve: { alias: { 'site.config': resolvedSiteConfigPath, + 'site.i18n': resolvedSiteI18nPath, }, plugins: [ new TsconfigPathsPlugin({ @@ -66,6 +70,7 @@ const config: Configuration = { filename: '[name].css', }), getHtmlWebpackPlugin(), + getI18nMessagesFallbackPlugin(), new ReactRefreshWebpackPlugin(), new ForkTsCheckerWebpackPlugin(), ], diff --git a/types.ts b/types.ts index 869007a9..d9edb693 100644 --- a/types.ts +++ b/types.ts @@ -25,7 +25,6 @@ export type AppProvider = FC<{ children?: ReactNode }>; export interface App { appId: string, - messages?: LocalizedMessages, routes?: RoleRouteObject[], providers?: AppProvider[], slots?: SlotOperation[], @@ -48,6 +47,7 @@ export interface RequiredSiteConfig { } export type LocalizedMessages = Record>; +export type SiteMessages = LocalizedMessages[]; export interface OptionalSiteConfig { // Site environment