diff --git a/.changeset/cli-translations-export-import.md b/.changeset/cli-translations-export-import.md new file mode 100644 index 0000000000..6631eebdfd --- /dev/null +++ b/.changeset/cli-translations-export-import.md @@ -0,0 +1,9 @@ +--- +'@backstage/cli': patch +--- + +Added `translations export` and `translations import` commands for managing translation files. + +The `translations export` command discovers all `TranslationRef` definitions across frontend plugin dependencies and exports their default messages as JSON files. The `translations import` command generates `TranslationResource` wiring code from translated JSON files, ready to be plugged into the app. + +Both commands support a `--pattern` option for controlling the message file layout, for example `--pattern '{lang}/{id}.json'` for language-based directory grouping. diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index 4eb5e864d1..2770f76799 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -1,12 +1,12 @@ --- id: internationalization -title: Internationalization (Experimental) -description: Documentation on adding internationalization to the plugin +title: Internationalization +description: Documentation on adding internationalization to plugins and apps --- ## Overview -The Backstage core function provides internationalization for plugins. The underlying library is [`i18next`](https://www.i18next.com/) with some additional Backstage typescript magic for type safety with keys. +The Backstage core function provides internationalization for plugins and apps. The underlying library is [`i18next`](https://www.i18next.com/) with some additional Backstage typescript magic for type safety with keys. ## For a plugin developer @@ -183,16 +183,56 @@ return ( The return type of the outer `t` function will be a `JSX.Element`, with the underlying value being a React fragment of the different parts of the message. -## For an application developer overwrite plugin messages +## For an application developer -Step 1: Create translation resources +As an app developer you can both override the default English messages of any plugin, and provide translations for additional languages. -You should separate different translations to their own files and import them in the main file: +### Overriding messages + +To customize specific messages without adding new languages, create a translation resource that overrides the default English messages: + +```ts +// packages/app/src/translations/catalog.ts + +import { createTranslationResource } from '@backstage/frontend-plugin-api'; +import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha'; + +export const catalogTranslations = createTranslationResource({ + ref: catalogTranslationRef, + translations: { + en: () => + Promise.resolve({ + default: { + 'indexPage.title': 'Service directory', + 'indexPage.createButtonTitle': 'Register new service', + }, + }), + }, +}); +``` + +Then register it in your app: + +```diff ++ import { catalogTranslations } from './translations/catalog'; + + const app = createApp({ ++ __experimentalTranslations: { ++ resources: [catalogTranslations], ++ }, + }) +``` + +You only need to include the keys you want to override — any missing keys fall back to the plugin's defaults. + +### Adding language translations + +To add support for additional languages, create translation resources with lazy-loaded message files for each language: ```ts // packages/app/src/translations/userSettings.ts -import { createTranslationResource } from '@backstage/core-plugin-api/alpha'; +import { createTranslationResource } from '@backstage/frontend-plugin-api'; import { userSettingsTranslationRef } from '@backstage/plugin-user-settings/alpha'; export const userSettingsTranslations = createTranslationResource({ @@ -203,10 +243,12 @@ export const userSettingsTranslations = createTranslationResource({ }); ``` +The translation messages can be defined using `createTranslationMessages` for type safety: + ```ts // packages/app/src/translations/userSettings-zh.ts -import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { createTranslationMessages } from '@backstage/frontend-plugin-api'; import { userSettingsTranslationRef } from '@backstage/plugin-user-settings/alpha'; const zh = createTranslationMessages({ @@ -221,7 +263,7 @@ const zh = createTranslationMessages({ export default zh; ``` -It's also possible to export the list of messages directly: +Or as a plain object export: ```ts // packages/app/src/translations/userSettings-zh.ts @@ -239,11 +281,7 @@ export default { }; ``` -You should change `zh` under the translations object to your local language. - -Step 2: Config translations in `packages/app/src/App.tsx` - -In an app you can both override the default messages, as well as register translations for additional languages: +Register it with the available languages declared: ```diff + import { userSettingsTranslations } from './translations/userSettings'; @@ -256,6 +294,116 @@ In an app you can both override the default messages, as well as register transl }) ``` -Step 3: Check everything is working correctly +Go to the Settings page — you should see language switching buttons. Switch languages to verify your translations are loaded correctly. -Go to `Settings` page, you should see change language buttons just under change theme buttons. And then switch language, you should see language had changed +### Using the CLI for full translation workflows + +When translating your app to other languages at scale — especially when working with external translation systems — the Backstage CLI provides `translations export` and `translations import` commands that automate the extraction and wiring of translation messages across all your plugin dependencies. + +#### Exporting default messages + +From your app package directory (e.g. `packages/app`), run: + +```bash +yarn backstage-cli translations export +``` + +This scans all frontend plugin dependencies (including transitive ones) for `TranslationRef` definitions and writes their default English messages as JSON files: + +```text +translations/ + manifest.json + messages/ + catalog.en.json + org.en.json + scaffolder.en.json + ... +``` + +Each `.en.json` file contains the flattened message keys and their default values: + +```json +{ + "indexPage.title": "All your components", + "indexPage.createButtonTitle": "Create new component", + "entityPage.notFound": "Entity not found" +} +``` + +#### Creating translations + +Copy the exported files and translate them for your target languages: + +```bash +cp translations/messages/catalog.en.json translations/messages/catalog.zh.json +``` + +Then edit `catalog.zh.json` with the translated strings. You only need to include the keys you want to translate — missing keys fall back to the English defaults at runtime. + +#### Generating wiring code + +Once you have translated files in place, run: + +```bash +yarn backstage-cli translations import +``` + +This generates a TypeScript module at `src/translations/resources.ts` that wires everything together: + +```ts +// This file is auto-generated by backstage-cli translations import +// Do not edit manually. + +import { createTranslationResource } from '@backstage/frontend-plugin-api'; +import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha'; + +export default [ + createTranslationResource({ + ref: catalogTranslationRef, + translations: { + zh: () => import('../../translations/messages/catalog.zh.json'), + }, + }), +]; +``` + +Import the generated resources in your app: + +```ts +import translationResources from './translations/resources'; + +const app = createApp({ + __experimentalTranslations: { + availableLanguages: ['en', 'zh'], + resources: translationResources, + }, +}); +``` + +#### Custom file patterns + +By default, message files use the pattern `messages/{id}.{lang}.json` (e.g. `messages/catalog.en.json`). You can change this with the `--pattern` option: + +```bash +yarn backstage-cli translations export --pattern '{lang}/{id}.json' +``` + +This produces a directory structure grouped by language instead: + +```text +translations/en/catalog.json +translations/zh/catalog.json +``` + +The pattern is stored in the manifest, so the `import` command automatically uses the same layout. + +#### Integration with external translation systems + +The exported JSON files are standard key-value pairs compatible with most external translation systems. A typical workflow looks like: + +1. Run `translations export` to generate the source English files +2. Upload the `.en.json` files to your translation system +3. Download the translated files back into the translations directory +4. Run `translations import` to regenerate the wiring code + +For full command reference, see the [CLI commands documentation](../tooling/cli/03-commands.md#translations-export). diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index 2a09cb1b11..085175c958 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -24,6 +24,7 @@ repo [command] Command that run across an entire package [command] Lifecycle scripts for individual packages migrate [command] Migration utilities versions:bump [options] Bump Backstage packages to the latest versions +translations [command] Translation message management clean Delete cache directories [DEPRECATED] build-workspace [packages...] Builds a temporary dist workspace from the provided packages @@ -429,6 +430,73 @@ YAML file that can be referenced in the GitHub integration configuration. Usage: backstage-cli create-github-app ``` +## translations export + +Export translation messages from an app and all of its frontend plugins to JSON +files. This command must be run from within a package directory (e.g. +`packages/app`), not from the repository root. + +The command discovers all `TranslationRef` definitions in the dependency tree, +extracts their default messages using the TypeScript type system, and writes +them as JSON files along with a manifest. + +For more details on the translation workflow, see the +[Internationalization](../../plugins/internationalization.md) documentation. + +```text +Usage: backstage-cli translations export [options] + +Options: + --output Output directory for exported messages and manifest (default: "translations") + --pattern File path pattern for message files relative to the output + directory, with {id} and {lang} placeholders + (default: "messages/{id}.{lang}.json") + -h, --help display help for command +``` + +### Examples + +Export translations with default settings: + +```bash +cd packages/app +yarn backstage-cli translations export +``` + +Export with language-based directory grouping: + +```bash +yarn backstage-cli translations export --pattern '{lang}/{id}.json' +``` + +## translations import + +Generate translation resource wiring code from translated JSON files. Reads the +manifest and translated message files produced by `translations export`, and +generates a TypeScript module that creates `TranslationResource` objects for each +translated ref. + +The file pattern used during export is stored in the manifest and automatically +used by the import command. + +```text +Usage: backstage-cli translations import [options] + +Options: + --input Input directory containing the manifest and translated message files (default: "translations") + --output Output path for the generated wiring module (default: "src/translations/resources.ts") + -h, --help display help for command +``` + +### Examples + +Generate wiring code with default settings: + +```bash +cd packages/app +yarn backstage-cli translations import +``` + ## info Outputs debug information which is useful when opening an issue. Outputs system diff --git a/mkdocs.yml b/mkdocs.yml index c07ff5afc4..c18d74494a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -142,6 +142,7 @@ nav: - Composability System: 'plugins/composability.md' - Plugin Analytics: 'plugins/analytics.md' - Feature Flags: 'plugins/feature-flags.md' + - Internationalization (i18n): 'plugins/internationalization.md' - OpenAPI: - Schema-first plugins with OpenAPI (Experimental): 'openapi/01-getting-started.md' - Generate a client from your OpenAPI spec: 'openapi/generate-client.md' diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 0948954ff7..ee4b03cbca 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -25,6 +25,7 @@ Commands: new package [command] repo [command] + translations [command] versions:bump versions:migrate ``` @@ -542,6 +543,44 @@ Options: -h, --help ``` +### `backstage-cli translations` + +``` +Usage: backstage-cli translations [options] [command] [command] + +Options: + -h, --help + +Commands: + export + help [command] + import +``` + +### `backstage-cli translations export` + +``` +Usage: + +Options: + --help + --output + --pattern + --version +``` + +### `backstage-cli translations import` + +``` +Usage: + +Options: + --help + --input + --output + --version +``` + ### `backstage-cli versions:bump` ``` diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ece1e413a0..9240ad876c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,5 +27,6 @@ import { CliInitializer } from './wiring/CliInitializer'; initializer.add(import('./modules/migrate')); initializer.add(import('./modules/new')); initializer.add(import('./modules/test')); + initializer.add(import('./modules/translations')); await initializer.run(); })(); diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts new file mode 100644 index 0000000000..96b3ceb0c6 --- /dev/null +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { paths } from '../../../lib/paths'; +import fs from 'fs-extra'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { + discoverFrontendPackages, + readTargetPackage, +} from '../lib/discoverPackages'; +import { + createTranslationProject, + extractTranslationRefsFromSourceFile, + TranslationRefInfo, +} from '../lib/extractTranslations'; +import { + DEFAULT_LANGUAGE, + formatMessagePath, + validatePattern, +} from '../lib/messageFilePath'; + +interface ExportOptions { + output: string; + pattern: string; +} + +export default async (options: ExportOptions) => { + validatePattern(options.pattern); + + const targetPackageJson = await readTargetPackage( + paths.targetDir, + paths.targetRoot, + ); + + const outputDir = resolvePath(paths.targetDir, options.output); + const manifestPath = resolvePath(outputDir, 'manifest.json'); + + const tsconfigPath = paths.resolveTargetRoot('tsconfig.json'); + if (!(await fs.pathExists(tsconfigPath))) { + throw new Error( + `No tsconfig.json found at ${tsconfigPath}. ` + + 'The translations export command requires a tsconfig.json in the repo root.', + ); + } + + console.log( + `Discovering frontend dependencies of ${targetPackageJson.name}...`, + ); + const packages = await discoverFrontendPackages( + targetPackageJson, + paths.targetDir, + ); + console.log(`Found ${packages.length} frontend packages to scan`); + + console.log('Creating TypeScript project...'); + const project = createTranslationProject(tsconfigPath); + + const allRefs: TranslationRefInfo[] = []; + + for (const pkg of packages) { + for (const [exportPath, filePath] of pkg.entryPoints) { + try { + const sourceFile = project.addSourceFileAtPath(filePath); + const refs = extractTranslationRefsFromSourceFile( + sourceFile, + pkg.name, + exportPath, + ); + allRefs.push(...refs); + } catch (error) { + console.warn( + ` Warning: failed to process ${pkg.name} (${exportPath}): ${error}`, + ); + } + } + } + + if (allRefs.length === 0) { + console.log('No translation refs found.'); + return; + } + + console.log(`Found ${allRefs.length} translation ref(s):`); + for (const ref of allRefs) { + const messageCount = Object.keys(ref.messages).length; + console.log(` ${ref.id} (${ref.packageName}, ${messageCount} messages)`); + } + + // Write message files using the configured pattern + for (const ref of allRefs) { + const relPath = formatMessagePath( + options.pattern, + ref.id, + DEFAULT_LANGUAGE, + ); + const filePath = resolvePath(outputDir, relPath); + await fs.ensureDir(dirname(filePath)); + await fs.writeJson(filePath, ref.messages, { spaces: 2 }); + } + + // Write manifest + const manifest: Record = {}; + for (const ref of allRefs) { + manifest[ref.id] = { + package: ref.packageName, + exportPath: ref.exportPath, + exportName: ref.exportName, + }; + } + await fs.writeJson( + manifestPath, + { pattern: options.pattern, refs: manifest }, + { spaces: 2 }, + ); + + const examplePath = formatMessagePath( + options.pattern, + '', + DEFAULT_LANGUAGE, + ); + console.log( + `\nExported ${allRefs.length} translation ref(s) to ${options.output}/`, + ); + console.log(` Messages: ${options.output}/${examplePath}`); + console.log(` Manifest: ${options.output}/manifest.json`); +}; diff --git a/packages/cli/src/modules/translations/commands/import.ts b/packages/cli/src/modules/translations/commands/import.ts new file mode 100644 index 0000000000..7275da4c24 --- /dev/null +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -0,0 +1,214 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { paths } from '../../../lib/paths'; +import fs from 'fs-extra'; +import { + resolve as resolvePath, + relative as relativePath, + sep, +} from 'node:path'; +import { readTargetPackage } from '../lib/discoverPackages'; +import { + DEFAULT_LANGUAGE, + createMessagePathParser, + formatMessagePath, +} from '../lib/messageFilePath'; + +interface ImportOptions { + input: string; + output: string; +} + +interface ManifestRefEntry { + package: string; + exportPath: string; + exportName: string; +} + +interface Manifest { + pattern?: string; + refs: Record; +} + +export default async (options: ImportOptions) => { + await readTargetPackage(paths.targetDir, paths.targetRoot); + + const inputDir = resolvePath(paths.targetDir, options.input); + const manifestPath = resolvePath(inputDir, 'manifest.json'); + const outputPath = resolvePath(paths.targetDir, options.output); + + if (!(await fs.pathExists(manifestPath))) { + throw new Error( + `No manifest.json found at ${manifestPath}. ` + + 'Run "backstage-cli translations export" first.', + ); + } + + const manifest: Manifest = await fs.readJson(manifestPath); + + if (!manifest.pattern) { + throw new Error( + 'No pattern found in manifest.json. Re-run "backstage-cli translations export" to regenerate it.', + ); + } + const pattern = manifest.pattern; + + const parsePath = createMessagePathParser(pattern); + + // Discover all JSON files under the translations directory + const allFiles = (await collectJsonFiles(inputDir)).filter( + f => f !== 'manifest.json', + ); + + // Parse each file to extract id + lang, filtering out default language files + const translationsByRef = new Map< + string, + Array<{ lang: string; relPath: string }> + >(); + let skipped = 0; + + for (const relPath of allFiles) { + const parsed = parsePath(relPath); + if (!parsed) { + skipped++; + continue; + } + + if (parsed.lang === DEFAULT_LANGUAGE) { + continue; + } + + if (!manifest.refs[parsed.id]) { + console.warn( + ` Warning: skipping ${relPath} - ref '${parsed.id}' not found in manifest`, + ); + continue; + } + + const existing = translationsByRef.get(parsed.id) ?? []; + existing.push({ lang: parsed.lang, relPath }); + translationsByRef.set(parsed.id, existing); + } + + if (skipped > 0) { + console.warn( + ` Warning: ${skipped} file(s) did not match the pattern '${pattern}'`, + ); + } + + if (translationsByRef.size === 0) { + console.log('No translated message files found.'); + const example = formatMessagePath(pattern, '', 'sv'); + console.log( + `Add translated files as ${example} in the translations directory.`, + ); + return; + } + + // Generate the wiring module + const importLines: string[] = []; + const resourceLines: string[] = []; + + importLines.push( + "import { createTranslationResource } from '@backstage/frontend-plugin-api';", + ); + + for (const [refId, entries] of [...translationsByRef.entries()].sort( + ([a], [b]) => a.localeCompare(b), + )) { + const refEntry = manifest.refs[refId]; + const importPath = + refEntry.exportPath === '.' + ? refEntry.package + : `${refEntry.package}/${refEntry.exportPath.replace(/^\.\//, '')}`; + + importLines.push(`import { ${refEntry.exportName} } from '${importPath}';`); + + const translationEntries = entries + .sort((a, b) => a.lang.localeCompare(b.lang)) + .map(({ lang, relPath }) => { + const jsonRelPath = relativePath( + resolvePath(outputPath, '..'), + resolvePath(inputDir, relPath), + ) + .split(sep) + .join('/'); + return ` ${JSON.stringify(lang)}: () => import('./${jsonRelPath}'),`; + }) + .join('\n'); + + resourceLines.push( + [ + ` createTranslationResource({`, + ` ref: ${refEntry.exportName},`, + ` translations: {`, + translationEntries, + ` },`, + ` }),`, + ].join('\n'), + ); + } + + const fileContent = [ + '// This file is auto-generated by backstage-cli translations import', + '// Do not edit manually.', + '', + ...importLines, + '', + 'export default [', + ...resourceLines, + '];', + '', + ].join('\n'); + + await fs.ensureDir(resolvePath(outputPath, '..')); + await fs.writeFile(outputPath, fileContent, 'utf8'); + + const totalFiles = [...translationsByRef.values()].reduce( + (sum, e) => sum + e.length, + 0, + ); + console.log(`Generated translation resources at ${options.output}`); + console.log( + ` ${translationsByRef.size} ref(s), ${totalFiles} translation file(s)`, + ); + console.log( + '\nImport this file in your app and pass the resources to your translation API setup.', + ); +}; + +/** + * Recursively collects all .json files under a directory, returning paths + * relative to that directory using forward slashes. + */ +async function collectJsonFiles(dir: string, prefix = ''): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const results: string[] = []; + + for (const entry of entries) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push( + ...(await collectJsonFiles(resolvePath(dir, entry.name), relPath)), + ); + } else if (entry.isFile() && entry.name.endsWith('.json')) { + results.push(relPath); + } + } + + return results; +} diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts new file mode 100644 index 0000000000..702b0f4f49 --- /dev/null +++ b/packages/cli/src/modules/translations/index.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import yargs from 'yargs'; +import { createCliPlugin } from '../../wiring/factory'; +import { lazy } from '../../lib/lazy'; +import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath'; + +export default createCliPlugin({ + pluginId: 'translations', + init: async reg => { + reg.addCommand({ + path: ['translations', 'export'], + description: + 'Export translation messages from an app and all of its frontend plugins to JSON files', + execute: async ({ args }) => { + const argv = await yargs() + .options({ + output: { + type: 'string', + default: 'translations', + description: + 'Output directory for exported messages and manifest', + }, + pattern: { + type: 'string', + default: DEFAULT_MESSAGE_PATTERN, + description: + 'File path pattern for message files, with {id} and {lang} placeholders', + }, + }) + .help() + .parse(args); + await lazy(() => import('./commands/export'), 'default')(argv); + }, + }); + + reg.addCommand({ + path: ['translations', 'import'], + description: + 'Generate translation resource wiring from translated JSON files', + execute: async ({ args }) => { + const argv = await yargs() + .options({ + input: { + type: 'string', + default: 'translations', + description: + 'Input directory containing the manifest and translated message files', + }, + output: { + type: 'string', + default: 'src/translations/resources.ts', + description: 'Output path for the generated wiring module', + }, + }) + .help() + .parse(args); + await lazy(() => import('./commands/import'), 'default')(argv); + }, + }); + }, +}); diff --git a/packages/cli/src/modules/translations/lib/discoverPackages.ts b/packages/cli/src/modules/translations/lib/discoverPackages.ts new file mode 100644 index 0000000000..c7a7602a7e --- /dev/null +++ b/packages/cli/src/modules/translations/lib/discoverPackages.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + BackstagePackageJson, + PackageGraph, + PackageRoles, +} from '@backstage/cli-node'; +import { dirname, resolve as resolvePath } from 'node:path'; +import fs from 'fs-extra'; + +/** A discovered package with its entry points resolved to file paths. */ +export interface DiscoveredPackage { + /** The package name, e.g. '@backstage/plugin-org' */ + name: string; + /** The directory of the package */ + dir: string; + /** Map of export subpath (e.g. '.', './alpha') to the resolved file path */ + entryPoints: Map; +} + +/** + * Reads the package.json from the given directory and validates that it + * is a workspace package (not the repo root). + */ +export async function readTargetPackage( + packageDir: string, + repoRoot: string, +): Promise { + const packageJsonPath = resolvePath(packageDir, 'package.json'); + + if (!(await fs.pathExists(packageJsonPath))) { + throw new Error( + 'No package.json found in the current directory. ' + + 'The translations commands must be run from within a package directory.', + ); + } + + if (resolvePath(packageDir) === resolvePath(repoRoot)) { + throw new Error( + 'The translations commands must be run from within a package directory, ' + + 'not from the repository root. For example: cd packages/app && backstage-cli translations export', + ); + } + + return fs.readJson(packageJsonPath); +} + +/** + * Discovers frontend packages that are transitive dependencies of the given + * target package and resolves their entry point file paths. Walks both + * workspace packages (source) and npm-installed packages (declaration files). + */ +export async function discoverFrontendPackages( + targetPackageJson: BackstagePackageJson, + targetDir: string, +): Promise { + // Build a lookup of workspace packages for preferring source over dist + let workspaceByName: Map< + string, + { packageJson: BackstagePackageJson; dir: string } + >; + try { + const workspacePackages = await PackageGraph.listTargetPackages(); + workspaceByName = new Map( + workspacePackages.map(p => [p.packageJson.name, p]), + ); + } catch { + workspaceByName = new Map(); + } + + const visited = new Set(); + const result: DiscoveredPackage[] = []; + + async function visit( + packageJson: BackstagePackageJson, + pkgDir: string, + includeDevDeps: boolean, + ) { + const deps: Record = { + ...packageJson.dependencies, + ...(includeDevDeps ? packageJson.devDependencies ?? {} : {}), + }; + + for (const depName of Object.keys(deps)) { + if (visited.has(depName)) { + continue; + } + visited.add(depName); + + let depPkgJson: BackstagePackageJson; + let depDir: string; + let isWorkspace: boolean; + + // Prefer workspace package (has source files) over npm-installed + const workspacePkg = workspaceByName.get(depName); + if (workspacePkg) { + depPkgJson = workspacePkg.packageJson; + depDir = workspacePkg.dir; + isWorkspace = true; + } else { + try { + const pkgJsonPath = require.resolve(`${depName}/package.json`, { + paths: [pkgDir], + }); + depPkgJson = await fs.readJson(pkgJsonPath); + depDir = dirname(pkgJsonPath); + isWorkspace = false; + } catch { + continue; + } + } + + // Only recurse into Backstage ecosystem packages + if (!depPkgJson.backstage) { + continue; + } + + const role = depPkgJson.backstage?.role; + if (role && isFrontendRole(role)) { + const entryPoints = resolveEntryPoints(depPkgJson, depDir, isWorkspace); + if (entryPoints.size > 0) { + result.push({ name: depName, dir: depDir, entryPoints }); + } + } + + // Walk this package's production dependencies for transitive refs + await visit(depPkgJson, depDir, false); + } + } + + // Start from the target, including its devDependencies + await visit(targetPackageJson, targetDir, true); + + return result; +} + +/** + * Resolves the entry points of a package to absolute file paths. + * For workspace packages, prefers source entry points (import/default). + * For npm packages, prefers type declaration entry points (.d.ts). + */ +function resolveEntryPoints( + packageJson: BackstagePackageJson, + packageDir: string, + isWorkspace: boolean, +): Map { + const entryPoints = new Map(); + + const exports = (packageJson as any).exports as + | Record> + | undefined; + + if (exports) { + for (const [subpath, target] of Object.entries(exports)) { + if (subpath === './package.json') { + continue; + } + + let filePath: string | undefined; + if (typeof target === 'string') { + filePath = target; + } else if (isWorkspace) { + // Workspace: exports point to source .ts files + filePath = target?.import ?? target?.types ?? target?.default; + } else { + // npm: prefer .d.ts for type-based extraction + filePath = target?.types ?? target?.import ?? target?.default; + } + + if (typeof filePath === 'string') { + entryPoints.set(subpath, resolvePath(packageDir, filePath)); + } + } + } else { + // Fallback: prefer types for npm, source for workspace + const main = isWorkspace + ? packageJson.main ?? packageJson.types + : packageJson.types ?? packageJson.main; + if (main) { + entryPoints.set('.', resolvePath(packageDir, main)); + } + } + + return entryPoints; +} + +function isFrontendRole(role: string): boolean { + try { + return PackageRoles.getRoleInfo(role).platform === 'web'; + } catch { + return false; + } +} diff --git a/packages/cli/src/modules/translations/lib/extractTranslations.test.ts b/packages/cli/src/modules/translations/lib/extractTranslations.test.ts new file mode 100644 index 0000000000..27b67db052 --- /dev/null +++ b/packages/cli/src/modules/translations/lib/extractTranslations.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve as resolvePath } from 'node:path'; +import { + createTranslationProject, + extractTranslationRefsFromSourceFile, +} from './extractTranslations'; + +describe('extractTranslations', () => { + it('extracts translation refs from the org plugin', () => { + const project = createTranslationProject( + resolvePath(__dirname, '../../../../../../tsconfig.json'), + ); + + const sourceFile = project.addSourceFileAtPath( + resolvePath(__dirname, '../../../../../..', 'plugins/org/src/alpha.tsx'), + ); + + const refs = extractTranslationRefsFromSourceFile( + sourceFile, + '@backstage/plugin-org', + './alpha', + ); + + expect(refs).toHaveLength(1); + expect(refs[0]).toMatchObject({ + id: 'org', + packageName: '@backstage/plugin-org', + exportPath: './alpha', + exportName: 'orgTranslationRef', + }); + + expect(refs[0].messages).toBeDefined(); + expect(Object.keys(refs[0].messages)).not.toHaveLength(0); + + // Verify some well-known keys exist without pinning exact wording + expect(refs[0].messages).toHaveProperty(['groupProfileCard.groupNotFound']); + expect(refs[0].messages).toHaveProperty(['membersListCard.title']); + + // Verify interpolation placeholders are preserved + expect(refs[0].messages['membersListCard.subtitle']).toContain( + '{{groupName}}', + ); + }); + + it('ignores non-TranslationRef exports', () => { + const project = createTranslationProject( + resolvePath(__dirname, '../../../../../../tsconfig.json'), + ); + + // The main entry of org plugin exports components but no translation ref + const sourceFile = project.addSourceFileAtPath( + resolvePath(__dirname, '../../../../../..', 'plugins/org/src/index.ts'), + ); + + const refs = extractTranslationRefsFromSourceFile( + sourceFile, + '@backstage/plugin-org', + '.', + ); + + expect(refs).toHaveLength(0); + }); + + it('extracts from the test fixtures translation ref', () => { + const project = createTranslationProject( + resolvePath(__dirname, '../../../../../../tsconfig.json'), + ); + + const sourceFile = project.addSourceFileAtPath( + resolvePath( + __dirname, + '../../../../../..', + 'packages/frontend-plugin-api/src/translation/__fixtures__/refs.ts', + ), + ); + + const refs = extractTranslationRefsFromSourceFile( + sourceFile, + '@backstage/frontend-plugin-api', + '.', + ); + + expect(refs).toHaveLength(2); + + const counting = refs.find(r => r.id === 'counting'); + expect(counting).toMatchObject({ + messages: { + one: 'one', + two: 'two', + three: 'three', + }, + }); + + const fruits = refs.find(r => r.id === 'fruits'); + expect(fruits).toMatchObject({ + messages: { + apple: 'apple', + orange: 'orange', + }, + }); + }); +}); diff --git a/packages/cli/src/modules/translations/lib/extractTranslations.ts b/packages/cli/src/modules/translations/lib/extractTranslations.ts new file mode 100644 index 0000000000..6278a08598 --- /dev/null +++ b/packages/cli/src/modules/translations/lib/extractTranslations.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Node, Project, SourceFile, Type, ts } from 'ts-morph'; + +/** Information about a discovered translation ref. */ +export interface TranslationRefInfo { + /** The ref ID, e.g. 'org' */ + id: string; + /** The package name, e.g. '@backstage/plugin-org' */ + packageName: string; + /** The subpath export where this ref is accessible, e.g. './alpha' or '.' */ + exportPath: string; + /** The exported symbol name, e.g. 'orgTranslationRef' */ + exportName: string; + /** Flattened message map: key -> default message string */ + messages: Record; +} + +/** + * Given a ts-morph SourceFile, finds all exported TranslationRef symbols + * and extracts their id and messages from the type system. + */ +export function extractTranslationRefsFromSourceFile( + sourceFile: SourceFile, + packageName: string, + exportPath: string, +): TranslationRefInfo[] { + const results: TranslationRefInfo[] = []; + + for (const exportSymbol of sourceFile.getExportSymbols()) { + const declarations = exportSymbol.getDeclarations(); + if (declarations.length === 0) { + continue; + } + + const declaration = declarations[0]; + const exportType = declaration.getType(); + + const refInfo = extractTranslationRefFromType(exportType, declaration); + if (!refInfo) { + continue; + } + + results.push({ + ...refInfo, + packageName, + exportPath, + exportName: exportSymbol.getName(), + }); + } + + return results; +} + +/** + * Checks whether a type is a TranslationRef by inspecting the $$type + * property on the target type, then extracts the id and messages from + * the type arguments of the generic instantiation. + */ +function extractTranslationRefFromType( + type: Type, + declaration: Node, +): Pick | undefined { + // Check the $$type property on the uninstantiated (target) type + const resolvedType = type.getTargetType() ?? type; + const $$typeProperty = resolvedType + .getProperties() + .find(p => p.getName() === '$$type'); + if (!$$typeProperty) { + return undefined; + } + const $$typeDecl = $$typeProperty.getValueDeclaration(); + if (!$$typeDecl) { + return undefined; + } + if (!$$typeDecl.getText().includes("'@backstage/TranslationRef'")) { + return undefined; + } + + // The type is TranslationRef - extract the type arguments + const typeArgs = type.getTypeArguments(); + if (typeArgs.length < 2) { + return undefined; + } + + const [idType, messagesType] = typeArgs; + + if (!idType.isStringLiteral()) { + return undefined; + } + const id = idType.getLiteralValueOrThrow() as string; + + // Extract messages from the TMessages type argument + const messages: Record = {}; + for (const messageProp of messagesType.getProperties()) { + const key = messageProp.getName(); + // Resolve the property type in the context of the declaration + const propType = messageProp.getTypeAtLocation(declaration); + if (propType.isStringLiteral()) { + messages[key] = propType.getLiteralValueOrThrow() as string; + } + } + + if (Object.keys(messages).length === 0) { + return undefined; + } + + return { id, messages }; +} + +/** + * Creates a ts-morph Project using the target repo's tsconfig.json. + */ +export function createTranslationProject(tsconfigPath: string): Project { + return new Project({ + tsConfigFilePath: tsconfigPath, + skipAddingFilesFromTsConfig: true, + }); +} diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.test.ts b/packages/cli/src/modules/translations/lib/messageFilePath.test.ts new file mode 100644 index 0000000000..4c276e114c --- /dev/null +++ b/packages/cli/src/modules/translations/lib/messageFilePath.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + formatMessagePath, + createMessagePathParser, + messagePatternToGlob, + patternHasSubdirectories, + DEFAULT_MESSAGE_PATTERN, +} from './messageFilePath'; + +describe('messageFilePath', () => { + describe('formatMessagePath', () => { + it('formats the default pattern', () => { + expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'org', 'en')).toBe( + 'messages/org.en.json', + ); + }); + + it('formats with a different language', () => { + expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'catalog', 'sv')).toBe( + 'messages/catalog.sv.json', + ); + }); + + it('formats a language-directory pattern', () => { + expect(formatMessagePath('{lang}/{id}.json', 'org', 'sv')).toBe( + 'sv/org.json', + ); + }); + + it('formats a pattern with lang first in filename', () => { + expect(formatMessagePath('{lang}.{id}.json', 'org', 'de')).toBe( + 'de.org.json', + ); + }); + }); + + describe('createMessagePathParser', () => { + it('parses the default pattern', () => { + const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN); + expect(parse('messages/org.en.json')).toEqual({ id: 'org', lang: 'en' }); + }); + + it('parses dotted ref IDs in the default pattern', () => { + const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN); + expect(parse('messages/plugin.notifications.sv.json')).toEqual({ + id: 'plugin.notifications', + lang: 'sv', + }); + }); + + it('parses a language-directory pattern', () => { + const parse = createMessagePathParser('{lang}/{id}.json'); + expect(parse('sv/org.json')).toEqual({ id: 'org', lang: 'sv' }); + }); + + it('returns undefined for non-matching paths', () => { + const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN); + expect(parse('not-a-match.txt')).toBeUndefined(); + expect(parse('other/org.en.json')).toBeUndefined(); + }); + + it('returns undefined for invalid language code', () => { + const parse = createMessagePathParser('{lang}/{id}.json'); + expect(parse('123/org.json')).toBeUndefined(); + }); + + it('throws on pattern missing {id}', () => { + expect(() => createMessagePathParser('{lang}.json')).toThrow( + 'must contain {id}', + ); + }); + + it('throws on pattern missing {lang}', () => { + expect(() => createMessagePathParser('{id}.json')).toThrow( + 'must contain {lang}', + ); + }); + + it('throws on pattern not ending with .json', () => { + expect(() => createMessagePathParser('{id}.{lang}.yaml')).toThrow( + 'must end with .json', + ); + }); + }); + + describe('messagePatternToGlob', () => { + it('converts the default pattern', () => { + expect(messagePatternToGlob(DEFAULT_MESSAGE_PATTERN)).toBe( + 'messages/*.*.json', + ); + }); + + it('converts a language-directory pattern', () => { + expect(messagePatternToGlob('{lang}/{id}.json')).toBe('*/*.json'); + }); + }); + + describe('patternHasSubdirectories', () => { + it('returns true for the default pattern', () => { + expect(patternHasSubdirectories(DEFAULT_MESSAGE_PATTERN)).toBe(true); + }); + + it('returns true for patterns with directories', () => { + expect(patternHasSubdirectories('{lang}/{id}.json')).toBe(true); + }); + }); +}); diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.ts b/packages/cli/src/modules/translations/lib/messageFilePath.ts new file mode 100644 index 0000000000..eb3dc8ea5c --- /dev/null +++ b/packages/cli/src/modules/translations/lib/messageFilePath.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The default language for exported translation messages. +export const DEFAULT_LANGUAGE = 'en'; + +// Default file path pattern for translation message files relative to the +// translations directory. Supported placeholders: {id} and {lang}. +export const DEFAULT_MESSAGE_PATTERN = 'messages/{id}.{lang}.json'; + +/** Formats a message file pattern into a concrete relative path. */ +export function formatMessagePath( + pattern: string, + id: string, + lang: string, +): string { + return pattern.replace(/\{id\}/g, id).replace(/\{lang\}/g, lang); +} + +/** Creates a parser that extracts id and lang from a relative file path. */ +export function createMessagePathParser( + pattern: string, +): (relativePath: string) => { id: string; lang: string } | undefined { + validatePattern(pattern); + + // Build a regex from the pattern by escaping special chars and replacing + // {id} and {lang} with named capture groups. + const escaped = pattern + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\\{id\\}/g, '(?[^/]+)') + .replace(/\\{lang\\}/g, '(?[a-z]{2})'); + + const regex = new RegExp(`^${escaped}$`); + + return (relPath: string) => { + const match = relPath.match(regex); + if (!match?.groups) { + return undefined; + } + return { id: match.groups.id, lang: match.groups.lang }; + }; +} + +/** Converts a message pattern into a glob string for discovering files. */ +export function messagePatternToGlob(pattern: string): string { + return pattern.replace(/\{id\}/g, '*').replace(/\{lang\}/g, '*'); +} + +/** Returns whether the pattern produces paths with subdirectories. */ +export function patternHasSubdirectories(pattern: string): boolean { + return pattern.includes('/'); +} + +export function validatePattern(pattern: string) { + if (!pattern.includes('{id}')) { + throw new Error( + `Invalid message file pattern: must contain {id} placeholder. Got: ${pattern}`, + ); + } + if (!pattern.includes('{lang}')) { + throw new Error( + `Invalid message file pattern: must contain {lang} placeholder. Got: ${pattern}`, + ); + } + if (!pattern.endsWith('.json')) { + throw new Error( + `Invalid message file pattern: must end with .json. Got: ${pattern}`, + ); + } +}