feat: add translations: CLI commands and update runtime i18n integration#205
feat: add translations: CLI commands and update runtime i18n integration#205arbrandes merged 15 commits intoopenedx:mainfrom
translations: CLI commands and update runtime i18n integration#205Conversation
translations:prepare and translations:pull CLI commands
translations:prepare and translations:pull CLI commandstranslations: CLI commands and update runtime i18n integration
bf7e911 to
874086f
Compare
brian-smith-tcril
left a comment
There was a problem hiding this comment.
Did a quick self-review to add context/spark conversation.
| import { addAppConfigs } from '../runtime/config'; | ||
| import { addAppMessages } from '../runtime/i18n'; | ||
| import messages from './i18n'; | ||
| import messages from 'site.i18n'; |
There was a problem hiding this comment.
see tools/webpack/utils/getResolvedSiteI18nPath.ts
| @@ -1,3 +0,0 @@ | |||
| # Test i18n directories | |||
|
|
|||
| These test files are used by the `src/i18n/scripts/intl-imports.test.js` file. | |||
There was a problem hiding this comment.
intl-imports no longer exists. It has been fully replaced by the openedx translations:pull and openedx translations:prepare commands.
| import { prepare, pull } from '../utils/translations'; | ||
|
|
||
| export function runPrepare(): void { | ||
| prepare({ siteRoot: process.cwd() }); | ||
| } | ||
|
|
||
| export function runPull(): void { | ||
| pull({ | ||
| siteRoot: process.cwd(), | ||
| execSync: (cmd) => child_process.execSync(cmd, { stdio: 'inherit' }), | ||
| shouldPrepare: !process.argv.includes('--no-prepare'), | ||
| atlasOptions: process.argv.find(a => a.startsWith('--atlas-options='))?.slice('--atlas-options='.length), | ||
| }); | ||
| } |
There was a problem hiding this comment.
The pull/runPull, prepare/runPrepare split is mostly to make testing easier, but it has an added benefit of separation of CLI concerns (pull doesn't need to parse args or know about child process stuff).
| 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! |
There was a problem hiding this comment.
Assuming the migration docs are followed, intl_imports and transifex_utils won't be used in frontend-app Makefiles anymore.
| // 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 { |
There was a problem hiding this comment.
Context on this one (from a claude plan doc):
src/i18n/messages.ts is gitignored and generated by prepare/translations:pull. src/i18n/index.ts is committed boilerplate that site operators can customize — by default it re-exports from ./messages. prepare's blast radius is intentionally limited to messages.ts; it never touches index.ts.
The current state in the repo has messages.ts force-added to git (via git add -f). The intent was to make it always exist on clone without tracking changes — but force-adding actually does track the file, overriding .gitignore. This means prepare regenerating it after translations:pull shows it as modified in git status, which is undesirable.
The correct fix: remove messages.ts from git tracking (git rm --cached), leave it gitignored as intended, and solve the "file doesn't exist on fresh clone" problem at the webpack level instead. On a fresh clone (before running translations:pull), webpack fails to resolve the ./messages import in index.ts and shows a "compiled with problems" overlay.
Approaches considered and rejected
- Commit a stub
messages.ts(not gitignored) — aftertranslations:pull+prepare,messages.tsshows as modified ingit status. Undesirable since translations should be invisible to git. - Run
preparebeforedev/build— rejected because dev/build should have nothing to do with translations; developers shouldn't need to think about translations just to run the app. postinstallscript — messy, especially with workspaces; adds fragility tonpm install.- Webpack stub creation (webpack config writes a stub
messages.tsif missing) — "doing something silly to make sure the file exists." try/catch require('./messages')inindex.ts— webpack statically analyzes allrequire()calls regardless of try/catch, still produces a "Module not found" warning and shows a "compiled with problems" overlay in the dev server. Also requires adeclare const requireand loses the clean static import inindex.ts, which site operators should be able to read and customize easily.- Alias
site.i18nto an empty module whenmessages.tsis missing — rejected becausesite.i18nmust always resolve to the app'ssrc/i18n/index.ts. Site operators are expected to customizeindex.ts(e.g. loading messages from a runtime API), so bypassing it entirely would silently ignore their customizations on fresh clone.
Chosen approach
Add a NormalModuleReplacementPlugin to frontend-base's webpack configs that transparently substitutes an empty module for the ./messages import when messages.ts is missing. This keeps index.ts as a clean static re-export, produces no webpack warnings, and is invisible to app developers and site operators.
| import path from 'path'; | ||
|
|
||
| export default function getResolvedSiteI18nPath(defaultDirname: string) { | ||
| const siteI18nPath = process.env.SITE_I18N_PATH; |
There was a problem hiding this comment.
I was following the pattern from getResolvedSiteConfigPath for this one. Could definitely simplify this if we don't try to handle any process.env stuff.
| "exclude": [ | ||
| "**/*.test.ts", | ||
| "**/*.test.js" | ||
| ] |
There was a problem hiding this comment.
From 02f65df
Without this the test files were getting added to dist.
| @@ -1,10 +1,11 @@ | |||
| { | |||
| "extends": "@tsconfig/node20/tsconfig.json", | |||
| "extends": "@tsconfig/node24/tsconfig.json", | |||
There was a problem hiding this comment.
Updated because I'm using mkdtempDisposable for some tests. We already have 24 in the .nvmrc so I figured updating this would be fine.
| "lint:fix": "eslint . --fix; npm run lint:fix:tools", | ||
| "lint:tools": "cd ./tools && eslint . && cd ..", | ||
| "lint:fix:tools": "cd ./tools && eslint . --fix && cd ..", |
There was a problem hiding this comment.
I like having lint:fix around as a script (especially when lint is multi-part so appending -- --fix doesn't "just work"), but I can remove it if we don't want it.
| }, | ||
| "peerDependencies": { | ||
| "@openedx/paragon": "^23.4.5", | ||
| "@openedx/paragon": "^23.20.0", |
There was a problem hiding this comment.
This is the first version with an atlasTranslations block in its package.json.
There was a problem hiding this comment.
Looks awesome! I would've pressed merge, but Claude caught a couple of things I think are worth addressing. The shell injection issue is the main one, but the second one might be worth a quick look, too.
1. Shell injection via execSync in pull.ts
Severity: Medium - requires a malicious package.json in node_modules or a crafted --atlas-options CLI argument.
In tools/cli/utils/translations/pull.ts#L148:
execSync(`atlas pull ${atlasOptions} ${atlasMappings}`);Both atlasOptions (from CLI --atlas-options=...) and atlasMappings (from atlasTranslations.path and name fields in dependency package.json files) are interpolated into a shell command string without any sanitization or escaping. Since child_process.execSync runs through /bin/sh, shell metacharacters in these values would be interpreted.
The atlasOptions vector requires a user to pass a malicious --atlas-options value, so it is low-risk in practice. The atlasMappings vector is more concerning: a compromised or malicious package in node_modules with a crafted atlasTranslations.path like foo; rm -rf /; echo would execute arbitrary commands during translations:pull.
Suggested fix: switch execSync to execFileSync('atlas', ['pull', ...args]) with arguments passed as an array, which bypasses shell interpretation entirely. This would also require the caller in tools/cli/commands/translations.ts#L11 to change its execSync signature accordingly.
2. getResolvedSiteI18nPath diverges from getResolvedSiteConfigPath behavior
Severity: Low - the functions handle missing paths differently, which could confuse future maintainers.
tools/webpack/utils/getResolvedSiteI18nPath.ts calls process.exit(1) when the default src/i18n directory doesn't exist, while getResolvedSiteConfigPath falls through to process.exit(1) only after checking both env var and default paths.
For site.i18n, this means npm run dev or npm run build will hard-abort if src/i18n/ doesn't exist, even though the getI18nMessagesFallbackPlugin was specifically designed to handle missing messages.ts gracefully. A site that hasn't set up src/i18n/index.ts yet will get a webpack-level crash rather than a helpful error. This is especially relevant on fresh clones before translations:pull has been run - the test-site has src/i18n/index.ts committed, but other sites might not.
Addressed in e2b256a
I think If we want to only
This part feels like a misunderstanding of how this is intended to work:
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…uild Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Atlas places files at <cwd>/<TO> directly. The TO was the bare package name (e.g. @openedx/frontend-app-authn), so files landed at the site root instead of src/i18n/messages/ where prepare expects them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `site.i18n` webpack alias (dev, build, dev-shell configs) using a
new `getResolvedSiteI18nPath` utility mirroring `getResolvedSiteConfigPath`
- Declare `site.i18n` module in `frontend-base.d.ts` using new `SiteMessages`
type (`LocalizedMessages[]`) added to `types.ts`
- Remove `App.messages`, `addAppMessages()`, and `shell/i18n/` placeholder —
all translations now flow through `initialize({ messages })` from the
site-level pull
- Add `test-site/src/i18n/index.ts` (empty array) for the dev shell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove `intl-imports` and `transifex-utils` CLI scripts and their test fixtures — both are superseded by `translations:pull`/`translations:prepare` and the updated extraction pipeline in alpha.16+ - Remove dead per-app i18n placeholder directories (`shell/dev/devHome/i18n/`, `test-site/src/authenticated-page/i18n/`) now that `App.messages` is gone - Update `tools/cli/README.md` and `docs/how_tos/migrate-frontend-app.md` to reflect the new translation workflow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
23.20.0 added atlasTranslations to package.json, enabling transitive translation resolution via openedx translations:pull. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When an app runs translations:pull directly, it acts as both the site and a package. Previously only atlasTranslations.dependencies were pulled; atlasTranslations.path on the top-level package was ignored. Refactored the resolver to start from the top-level package.json and recurse into dependencies uniformly, so the running app's own translations are included alongside its dependency translations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When translations:pull hasn't been run yet, messages.ts doesn't exist and webpack would fail to resolve the import in src/i18n/index.ts. NormalModuleReplacementPlugin now substitutes an empty messages module when the file is missing, so the dev server and build work on a fresh clone without requiring translations to be pulled first. Also documents the required src/i18n/index.ts and messages.d.ts files in the app migration guide. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
e2b256a to
3e4b719
Compare
arbrandes
left a comment
There was a problem hiding this comment.
Ok, only the potential security issue was really worrisome. It seems the missing-path behavior is not likely to ever happen, in practice. Approved!
|
🎉 This PR is included in version 1.0.0-alpha.22 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
Adds two new CLI commands for managing translations, plus the runtime wiring to feed pulled translations into the app.
CLI commands:
translations:prepare— generatessrc/i18n/messages.tsand per-packageindex.tsfiles from the locale JSON files insrc/i18n/messages/andsrc/i18n/site-messages/.translations:pull— resolvesatlasTranslationsfrom the site's ownpackage.jsonandatlasTranslations.dependenciestransitively from each dependency'spackage.json, callsatlas pullwith the resultingFROM:TOmappings, then runstranslations:prepare.Runtime integration:
site.i18nwebpack alias (mirrorssite.config) pointing atsrc/i18n/in the site. AgetResolvedSiteI18nPathutility handles resolution, withSITE_I18N_PATHenv var override support.shell/site.tsxnow imports messages fromsite.i18ninstead of the shell-internal placeholder.App.messagesfrom theApptype andaddAppMessages()from the runtime — all translations now flow throughinitialize({ messages })from the site-level pull.SiteMessagestype (LocalizedMessages[]) totypes.tsand declaressite.i18ninfrontend-base.d.ts.shell/i18n/placeholder directory (was for the oldmake pull_translationssystem).NormalModuleReplacementPluginto webpack configs that substitutes an empty module for the./messagesimport insrc/i18n/whenmessages.tshasn't been generated yet, so the app builds cleanly on a fresh clone beforetranslations:pullhas been run.Structure
All logic lives in
tools/cli/utils/translations/:Directory layout (after pull)
Gitignore entries for
src/i18n/:CLI usage
Makefile parity
The old
pull_translationsMakefile target usedATLAS_OPTIONS,ATLAS_EXTRA_SOURCES, andATLAS_EXTRA_INTL_IMPORTS. This PR covers:ATLAS_OPTIONS→--atlas-options="..."CLI arg, passed through as a raw stringATLAS_EXTRA_SOURCES→ add entries toatlasTranslations.dependenciesinpackage.jsonATLAS_EXTRA_INTL_IMPORTS→ not applicable;translations:preparederives its inputs from the messages directory directlyFor backwards compatibility with how
ATLAS_OPTIONSis passed via Makefile, sites should have apull_translationstarget like:Also included
lint:fixandlint:fix:toolsnpm scriptstools/tsconfig.jsonfrom@tsconfig/node20to@tsconfig/node24(project runs on Node 24)@types/nodefrom^18to^24tools/tsconfig.build.jsonto prevent build errors from Node 24-only test APIs"types": ["jest"]totools/tsconfig.jsonto fix VS Code not recognizing Jest globals in test filesImplementation log
Full design context and implementation decisions: https://gist.github.com/brian-smith-tcril/548f661196820ebe89a0a6ba9a29cf61
Before moving out of draft
atlasTranslationstofrontend-base/package.jsonwith the correct atlas pathfrontend-template-sitepointing at branches of each dependency that haveatlasTranslations, and verify end-to-end pull and prepare behavioratlasTranslationstofrontend-app-authn/package.jsonatlasTranslationstofrontend-app-learner-dashboard/package.jsonatlasTranslationstoparagon/package.jsonfrontend-basetranslations toopenedx/openedx-translations🤖 Generated with Claude Code