From a8a853eb1c42baaa0d3eb94476fe5a98d0619eb1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Feb 2026 22:36:19 +0100 Subject: [PATCH 01/11] Add `translations export` and `translations import` CLI commands Adds tooling for exporting and importing translation strings to enable integration with external TMS tools like Smartling. The export command uses the TypeScript compiler API (ts-morph) to discover TranslationRef exports from all frontend plugin dependencies of the current package, extracting message keys and default values from the type system. The import command generates TranslationResource wiring from translated JSON files downloaded from a TMS. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- packages/cli/src/index.ts | 1 + .../modules/translations/commands/export.ts | 119 ++++++++++++ .../modules/translations/commands/import.ts | 171 ++++++++++++++++++ .../cli/src/modules/translations/index.ts | 68 +++++++ .../translations/lib/discoverPackages.ts | 155 ++++++++++++++++ .../lib/extractTranslations.test.ts | 121 +++++++++++++ .../translations/lib/extractTranslations.ts | 133 ++++++++++++++ 7 files changed, 768 insertions(+) create mode 100644 packages/cli/src/modules/translations/commands/export.ts create mode 100644 packages/cli/src/modules/translations/commands/import.ts create mode 100644 packages/cli/src/modules/translations/index.ts create mode 100644 packages/cli/src/modules/translations/lib/discoverPackages.ts create mode 100644 packages/cli/src/modules/translations/lib/extractTranslations.test.ts create mode 100644 packages/cli/src/modules/translations/lib/extractTranslations.ts 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..5366cdfeab --- /dev/null +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -0,0 +1,119 @@ +/* + * 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 } from 'node:path'; +import { + discoverFrontendPackages, + readTargetPackage, +} from '../lib/discoverPackages'; +import { + createTranslationProject, + extractTranslationRefsFromSourceFile, + TranslationRefInfo, +} from '../lib/extractTranslations'; + +interface ExportOptions { + output: string; +} + +export default async (options: ExportOptions) => { + const targetPackageJson = await readTargetPackage( + paths.targetDir, + paths.targetRoot, + ); + + const outputDir = resolvePath(paths.targetDir, options.output, 'messages'); + const manifestPath = resolvePath( + paths.targetDir, + options.output, + '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); + 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 + await fs.ensureDir(outputDir); + + for (const ref of allRefs) { + const filePath = resolvePath(outputDir, `${ref.id}.en.json`); + 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, { refs: manifest }, { spaces: 2 }); + + console.log( + `\nExported ${allRefs.length} translation ref(s) to ${options.output}/`, + ); + console.log(` Messages: ${options.output}/messages/.en.json`); + 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..e279239a38 --- /dev/null +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -0,0 +1,171 @@ +/* + * 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 } from 'node:path'; +import { readTargetPackage } from '../lib/discoverPackages'; + +interface ImportOptions { + input: string; + output: string; +} + +interface ManifestRefEntry { + package: string; + exportPath: string; + exportName: string; +} + +interface Manifest { + refs: Record; +} + +export default async (options: ImportOptions) => { + await readTargetPackage(paths.targetDir, paths.targetRoot); + + const inputDir = resolvePath(paths.targetDir, options.input); + const messagesDir = resolvePath(inputDir, 'messages'); + 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.', + ); + } + + if (!(await fs.pathExists(messagesDir))) { + throw new Error( + `No messages directory found at ${messagesDir}. ` + + 'Run "backstage-cli translations export" first.', + ); + } + + const manifest: Manifest = await fs.readJson(manifestPath); + + // Find all translated (non-English) message files + const files = await fs.readdir(messagesDir); + const translatedFiles = files.filter( + f => f.endsWith('.json') && !f.endsWith('.en.json'), + ); + + if (translatedFiles.length === 0) { + console.log('No translated message files found.'); + console.log( + 'Add translated files as ..json in the messages/ directory.', + ); + return; + } + + // Group translations by ref ID + const translationsByRef = new Map(); + for (const file of translatedFiles) { + // Expected format: ..json + const match = file.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/); + if (!match) { + console.warn(` Warning: skipping file with unexpected name: ${file}`); + continue; + } + + const [, refId, language] = match; + if (!manifest.refs[refId]) { + console.warn( + ` Warning: skipping ${file} - ref '${refId}' not found in manifest`, + ); + continue; + } + + const existing = translationsByRef.get(refId) ?? []; + existing.push(language); + translationsByRef.set(refId, existing); + } + + if (translationsByRef.size === 0) { + console.log('No valid translation files found for known refs.'); + return; + } + + // Generate the wiring module + const importLines: string[] = []; + const resourceLines: string[] = []; + + importLines.push( + "import { createTranslationResource } from '@backstage/frontend-plugin-api';", + ); + + for (const [refId, languages] 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 messagesRelPath = relativePath( + resolvePath(outputPath, '..'), + messagesDir, + ); + + const translationEntries = languages + .sort() + .map( + lang => + ` ${JSON.stringify( + lang, + )}: () => import('./${messagesRelPath}/${refId}.${lang}.json'),`, + ) + .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'); + + console.log(`Generated translation resources at ${options.output}`); + console.log( + ` ${translationsByRef.size} ref(s), ${translatedFiles.length} translation file(s)`, + ); + console.log( + '\nImport this file in your app and pass the resources to your translation API setup.', + ); +}; diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts new file mode 100644 index 0000000000..29207fa548 --- /dev/null +++ b/packages/cli/src/modules/translations/index.ts @@ -0,0 +1,68 @@ +/* + * 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'; + +export default createCliPlugin({ + pluginId: 'translations', + init: async reg => { + reg.addCommand({ + path: ['translations', 'export'], + description: + 'Export translation messages from all 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', + }, + }) + .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..895c254933 --- /dev/null +++ b/packages/cli/src/modules/translations/lib/discoverPackages.ts @@ -0,0 +1,155 @@ +/* + * 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, + PackageRole, +} from '@backstage/cli-node'; +import { resolve as resolvePath } from 'node:path'; +import fs from 'fs-extra'; + +/** + * Package roles that can contain frontend translation refs. + */ +const FRONTEND_ROLES: PackageRole[] = [ + 'frontend', + 'frontend-plugin', + 'frontend-plugin-module', + 'web-library', + 'common-library', +]; + +/** 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 dependencies of the given target + * package and resolves their entry point file paths. + */ +export async function discoverFrontendPackages( + targetPackageJson: BackstagePackageJson, +): Promise { + const allPackages = await PackageGraph.listTargetPackages(); + const packagesByName = new Map(allPackages.map(p => [p.packageJson.name, p])); + + // Collect all direct dependencies of the target package + const depNames = new Set([ + ...Object.keys(targetPackageJson.dependencies ?? {}), + ...Object.keys((targetPackageJson as any).devDependencies ?? {}), + ]); + + const result: DiscoveredPackage[] = []; + + for (const depName of depNames) { + const pkg = packagesByName.get(depName); + if (!pkg) { + continue; + } + + const role = pkg.packageJson.backstage?.role; + if (!role || !FRONTEND_ROLES.includes(role)) { + continue; + } + + const entryPoints = resolveEntryPoints(pkg.packageJson, pkg.dir); + if (entryPoints.size > 0) { + result.push({ + name: pkg.packageJson.name, + dir: pkg.dir, + entryPoints, + }); + } + } + + return result; +} + +/** + * Resolves the entry points of a package to absolute file paths. + * Uses the `exports` field from package.json, falling back to `main`/`types`. + */ +function resolveEntryPoints( + packageJson: BackstagePackageJson, + packageDir: string, +): Map { + const entryPoints = new Map(); + + const exports = (packageJson as any).exports as + | Record> + | undefined; + + if (exports) { + for (const [subpath, target] of Object.entries(exports)) { + // Skip package.json entry + if (subpath === './package.json') { + continue; + } + + // The target can be a string or a conditions map + const filePath = + typeof target === 'string' + ? target + : target?.import ?? target?.types ?? target?.default; + + if (typeof filePath === 'string') { + entryPoints.set(subpath, resolvePath(packageDir, filePath)); + } + } + } else { + // Fallback to main/types + const main = packageJson.types ?? packageJson.main; + if (main) { + entryPoints.set('.', resolvePath(packageDir, main)); + } + } + + return entryPoints; +} 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..8b46c63eff --- /dev/null +++ b/packages/cli/src/modules/translations/lib/extractTranslations.test.ts @@ -0,0 +1,121 @@ +/* + * 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', + }); + + // Verify a subset of messages + expect(refs[0].messages).toMatchObject({ + 'groupProfileCard.groupNotFound': 'Group not found', + 'membersListCard.title': 'Members', + 'membersListCard.subtitle': 'of {{groupName}}', + 'userProfileCard.userNotFound': 'User not found', + }); + + // Verify interpolation placeholders are preserved + expect(refs[0].messages['membersListCard.paginationLabel']).toBe( + ', page {{page}} of {{nbPages}}', + ); + + // Verify total message count + expect(Object.keys(refs[0].messages).length).toBe(26); + }); + + 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, + }); +} From d404ab30cafaae74d2fbe9c832f30f71481b3ee7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Feb 2026 23:29:31 +0100 Subject: [PATCH 02/11] cli: walk transitive deps and resolve npm packages for translation export The package discovery for the translations export command previously only looked at direct dependencies and only workspace packages. This changes it to recursively walk the full transitive dependency tree and resolve packages from node_modules when they aren't found in the workspace. Entry point resolution now also prefers .d.ts type declarations for npm-installed packages. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .../modules/translations/commands/export.ts | 5 +- .../translations/lib/discoverPackages.ts | 127 +++++++++++++----- 2 files changed, 95 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index 5366cdfeab..e8371a7655 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -55,7 +55,10 @@ export default async (options: ExportOptions) => { console.log( `Discovering frontend dependencies of ${targetPackageJson.name}...`, ); - const packages = await discoverFrontendPackages(targetPackageJson); + const packages = await discoverFrontendPackages( + targetPackageJson, + paths.targetDir, + ); console.log(`Found ${packages.length} frontend packages to scan`); console.log('Creating TypeScript project...'); diff --git a/packages/cli/src/modules/translations/lib/discoverPackages.ts b/packages/cli/src/modules/translations/lib/discoverPackages.ts index 895c254933..b8793accd5 100644 --- a/packages/cli/src/modules/translations/lib/discoverPackages.ts +++ b/packages/cli/src/modules/translations/lib/discoverPackages.ts @@ -19,7 +19,7 @@ import { PackageGraph, PackageRole, } from '@backstage/cli-node'; -import { resolve as resolvePath } from 'node:path'; +import { dirname, resolve as resolvePath } from 'node:path'; import fs from 'fs-extra'; /** @@ -71,54 +71,103 @@ export async function readTargetPackage( } /** - * Discovers frontend packages that are dependencies of the given target - * package and resolves their entry point file paths. + * 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 { - const allPackages = await PackageGraph.listTargetPackages(); - const packagesByName = new Map(allPackages.map(p => [p.packageJson.name, p])); - - // Collect all direct dependencies of the target package - const depNames = new Set([ - ...Object.keys(targetPackageJson.dependencies ?? {}), - ...Object.keys((targetPackageJson as any).devDependencies ?? {}), - ]); + // 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[] = []; - for (const depName of depNames) { - const pkg = packagesByName.get(depName); - if (!pkg) { - continue; - } + async function visit( + packageJson: BackstagePackageJson, + pkgDir: string, + includeDevDeps: boolean, + ) { + const deps: Record = { + ...packageJson.dependencies, + ...(includeDevDeps ? (packageJson as any).devDependencies : {}), + }; - const role = pkg.packageJson.backstage?.role; - if (!role || !FRONTEND_ROLES.includes(role)) { - continue; - } + for (const depName of Object.keys(deps)) { + if (visited.has(depName)) { + continue; + } + visited.add(depName); - const entryPoints = resolveEntryPoints(pkg.packageJson, pkg.dir); - if (entryPoints.size > 0) { - result.push({ - name: pkg.packageJson.name, - dir: pkg.dir, - entryPoints, - }); + 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 && FRONTEND_ROLES.includes(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. - * Uses the `exports` field from package.json, falling back to `main`/`types`. + * 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(); @@ -128,24 +177,30 @@ function resolveEntryPoints( if (exports) { for (const [subpath, target] of Object.entries(exports)) { - // Skip package.json entry if (subpath === './package.json') { continue; } - // The target can be a string or a conditions map - const filePath = - typeof target === 'string' - ? target - : target?.import ?? target?.types ?? target?.default; + 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 to main/types - const main = packageJson.types ?? packageJson.main; + // 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)); } From 356cff278fa6d20731d6eb3256bc6e46e5de3581 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 00:31:18 +0100 Subject: [PATCH 03/11] cli: add configurable --pattern option for translation message files Adds a --pattern option to both translations export and import commands that controls the file path layout of message files within the messages/ directory. The default pattern is {id}.{lang}.json (unchanged behavior), but users can pass e.g. --pattern '{lang}/{id}.json' for language-based directory grouping. The pattern is stored in the manifest so that import picks it up automatically. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .../modules/translations/commands/export.ts | 31 +++-- .../modules/translations/commands/import.ts | 130 ++++++++++++------ .../cli/src/modules/translations/index.ts | 13 ++ .../translations/lib/messageFilePath.test.ts | 120 ++++++++++++++++ .../translations/lib/messageFilePath.ts | 83 +++++++++++ 5 files changed, 327 insertions(+), 50 deletions(-) create mode 100644 packages/cli/src/modules/translations/lib/messageFilePath.test.ts create mode 100644 packages/cli/src/modules/translations/lib/messageFilePath.ts diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index e8371a7655..ca8bd2c60b 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -16,7 +16,7 @@ import { paths } from '../../../lib/paths'; import fs from 'fs-extra'; -import { resolve as resolvePath } from 'node:path'; +import { dirname, resolve as resolvePath } from 'node:path'; import { discoverFrontendPackages, readTargetPackage, @@ -26,9 +26,11 @@ import { extractTranslationRefsFromSourceFile, TranslationRefInfo, } from '../lib/extractTranslations'; +import { DEFAULT_LANGUAGE, formatMessagePath } from '../lib/messageFilePath'; interface ExportOptions { output: string; + pattern: string; } export default async (options: ExportOptions) => { @@ -37,7 +39,7 @@ export default async (options: ExportOptions) => { paths.targetRoot, ); - const outputDir = resolvePath(paths.targetDir, options.output, 'messages'); + const messagesDir = resolvePath(paths.targetDir, options.output, 'messages'); const manifestPath = resolvePath( paths.targetDir, options.output, @@ -95,11 +97,15 @@ export default async (options: ExportOptions) => { console.log(` ${ref.id} (${ref.packageName}, ${messageCount} messages)`); } - // Write message files - await fs.ensureDir(outputDir); - + // Write message files using the configured pattern for (const ref of allRefs) { - const filePath = resolvePath(outputDir, `${ref.id}.en.json`); + const relPath = formatMessagePath( + options.pattern, + ref.id, + DEFAULT_LANGUAGE, + ); + const filePath = resolvePath(messagesDir, relPath); + await fs.ensureDir(dirname(filePath)); await fs.writeJson(filePath, ref.messages, { spaces: 2 }); } @@ -112,11 +118,20 @@ export default async (options: ExportOptions) => { exportName: ref.exportName, }; } - await fs.writeJson(manifestPath, { refs: manifest }, { spaces: 2 }); + 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}/messages/.en.json`); + console.log(` Messages: ${options.output}/messages/${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 index e279239a38..b652eb524e 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -16,12 +16,22 @@ import { paths } from '../../../lib/paths'; import fs from 'fs-extra'; -import { resolve as resolvePath, relative as relativePath } from 'node:path'; +import { + resolve as resolvePath, + relative as relativePath, + posix as posixPath, +} from 'node:path'; import { readTargetPackage } from '../lib/discoverPackages'; +import { + DEFAULT_LANGUAGE, + createMessagePathParser, + formatMessagePath, +} from '../lib/messageFilePath'; interface ImportOptions { input: string; output: string; + pattern: string; } interface ManifestRefEntry { @@ -31,6 +41,7 @@ interface ManifestRefEntry { } interface Manifest { + pattern?: string; refs: Record; } @@ -58,45 +69,56 @@ export default async (options: ImportOptions) => { const manifest: Manifest = await fs.readJson(manifestPath); - // Find all translated (non-English) message files - const files = await fs.readdir(messagesDir); - const translatedFiles = files.filter( - f => f.endsWith('.json') && !f.endsWith('.en.json'), - ); + // Use the pattern from manifest if available, falling back to the CLI default + const pattern = manifest.pattern ?? options.pattern; - if (translatedFiles.length === 0) { - console.log('No translated message files found.'); - console.log( - 'Add translated files as ..json in the messages/ directory.', - ); - return; - } + const parsePath = createMessagePathParser(pattern); - // Group translations by ref ID - const translationsByRef = new Map(); - for (const file of translatedFiles) { - // Expected format: ..json - const match = file.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/); - if (!match) { - console.warn(` Warning: skipping file with unexpected name: ${file}`); + // Discover all JSON files under the messages directory + const allFiles = await collectJsonFiles(messagesDir); + + // 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; } - const [, refId, language] = match; - if (!manifest.refs[refId]) { + if (parsed.lang === DEFAULT_LANGUAGE) { + continue; + } + + if (!manifest.refs[parsed.id]) { console.warn( - ` Warning: skipping ${file} - ref '${refId}' not found in manifest`, + ` Warning: skipping ${relPath} - ref '${parsed.id}' not found in manifest`, ); continue; } - const existing = translationsByRef.get(refId) ?? []; - existing.push(language); - translationsByRef.set(refId, existing); + 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 valid translation files found for known refs.'); + console.log('No translated message files found.'); + const example = formatMessagePath(pattern, '', 'sv'); + console.log( + `Add translated files as messages/${example} in the translations directory.`, + ); return; } @@ -108,7 +130,7 @@ export default async (options: ImportOptions) => { "import { createTranslationResource } from '@backstage/frontend-plugin-api';", ); - for (const [refId, languages] of [...translationsByRef.entries()].sort( + for (const [refId, entries] of [...translationsByRef.entries()].sort( ([a], [b]) => a.localeCompare(b), )) { const refEntry = manifest.refs[refId]; @@ -119,19 +141,17 @@ export default async (options: ImportOptions) => { importLines.push(`import { ${refEntry.exportName} } from '${importPath}';`); - const messagesRelPath = relativePath( - resolvePath(outputPath, '..'), - messagesDir, - ); - - const translationEntries = languages - .sort() - .map( - lang => - ` ${JSON.stringify( - lang, - )}: () => import('./${messagesRelPath}/${refId}.${lang}.json'),`, - ) + const translationEntries = entries + .sort((a, b) => a.lang.localeCompare(b.lang)) + .map(({ lang, relPath }) => { + const jsonRelPath = posixPath.normalize( + relativePath( + resolvePath(outputPath, '..'), + resolvePath(messagesDir, relPath), + ), + ); + return ` ${JSON.stringify(lang)}: () => import('./${jsonRelPath}'),`; + }) .join('\n'); resourceLines.push( @@ -161,11 +181,37 @@ export default async (options: ImportOptions) => { 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), ${translatedFiles.length} translation file(s)`, + ` ${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 index 29207fa548..7a7d33a28a 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli/src/modules/translations/index.ts @@ -16,6 +16,7 @@ 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', @@ -33,6 +34,12 @@ export default createCliPlugin({ 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); @@ -58,6 +65,12 @@ export default createCliPlugin({ default: 'src/translations/resources.ts', description: 'Output path for the generated wiring module', }, + pattern: { + type: 'string', + default: DEFAULT_MESSAGE_PATTERN, + description: + 'File path pattern for message files, with {id} and {lang} placeholders', + }, }) .help() .parse(args); 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..6a6f3a8873 --- /dev/null +++ b/packages/cli/src/modules/translations/lib/messageFilePath.test.ts @@ -0,0 +1,120 @@ +/* + * 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( + 'org.en.json', + ); + }); + + it('formats with a different language', () => { + expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'catalog', 'sv')).toBe( + '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('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('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('dir/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('*.*.json'); + }); + + it('converts a language-directory pattern', () => { + expect(messagePatternToGlob('{lang}/{id}.json')).toBe('*/*.json'); + }); + }); + + describe('patternHasSubdirectories', () => { + it('returns false for flat patterns', () => { + expect(patternHasSubdirectories(DEFAULT_MESSAGE_PATTERN)).toBe(false); + }); + + 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..5f84c1ad13 --- /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. +// Supported placeholders: {id} (ref ID) and {lang} (language code). +export const DEFAULT_MESSAGE_PATTERN = '{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('/'); +} + +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}`, + ); + } +} From fd50cb3401543c6c9e8dcf3b13fca5136e5e20dc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 00:41:23 +0100 Subject: [PATCH 04/11] docs: add changeset and documentation for translations CLI commands Adds a changeset for the new translations export/import CLI commands. Updates the internationalization docs with the CLI-based workflow as the recommended approach for managing translations, and adds the commands to the CLI reference. Also adds the internationalization page to the mkdocs navigation. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .changeset/cli-translations-export-import.md | 9 + docs/plugins/internationalization.md | 180 +++++++++++++++++-- docs/tooling/cli/03-commands.md | 66 +++++++ mkdocs.yml | 1 + 4 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 .changeset/cli-translations-export-import.md diff --git a/.changeset/cli-translations-export-import.md b/.changeset/cli-translations-export-import.md new file mode 100644 index 0000000000..38377d23ef --- /dev/null +++ b/.changeset/cli-translations-export-import.md @@ -0,0 +1,9 @@ +--- +'@backstage/cli': minor +--- + +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..f1d5d8a81d 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 translation management systems (TMS) like Smartling, Crowdin, or Lokalise — 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 `{id}.{lang}.json` (e.g. `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/messages/en/catalog.json +translations/messages/zh/catalog.json +``` + +The pattern is stored in the manifest, so the `import` command automatically uses the same layout. + +#### Integration with a TMS + +The exported JSON files are standard key-value pairs compatible with most translation management systems. A typical workflow looks like: + +1. Run `translations export` to generate the source English files +2. Upload the `.en.json` files to your TMS +3. Download the translated files from your TMS into the `messages/` 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 0a0931f1db..3a3af67d5d 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 @@ -428,6 +429,71 @@ YAML file that can be referenced in the GitHub integration configuration. Usage: backstage-cli create-github-app ``` +## translations export + +Export translation messages from all frontend plugin dependencies of the current +package. 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, with {id} and {lang} + placeholders (default: "{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. + +```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") + --pattern File path pattern for message files, with {id} and {lang} + placeholders (default: "{id}.{lang}.json") + -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' From a808c9ef3ff0dcb48a4e0a77a54389d278c059ab Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 12:22:26 +0100 Subject: [PATCH 05/11] cli: use PackageRoles for role detection and remove redundant import --pattern Uses PackageRoles.getRoleInfo from cli-node to determine frontend packages by platform instead of a hardcoded role list. Removes the --pattern option from the import command since the pattern is always read from the manifest. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- docs/tooling/cli/03-commands.md | 11 +++++---- .../modules/translations/commands/import.ts | 9 +++++--- .../cli/src/modules/translations/index.ts | 6 ----- .../translations/lib/discoverPackages.ts | 23 ++++++++----------- 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index 3a3af67d5d..efdf197113 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -474,15 +474,16 @@ 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") - --pattern File path pattern for message files, with {id} and {lang} - placeholders (default: "{id}.{lang}.json") - -h, --help display help for command + --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 diff --git a/packages/cli/src/modules/translations/commands/import.ts b/packages/cli/src/modules/translations/commands/import.ts index b652eb524e..35b554ce30 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -31,7 +31,6 @@ import { interface ImportOptions { input: string; output: string; - pattern: string; } interface ManifestRefEntry { @@ -69,8 +68,12 @@ export default async (options: ImportOptions) => { const manifest: Manifest = await fs.readJson(manifestPath); - // Use the pattern from manifest if available, falling back to the CLI default - const pattern = manifest.pattern ?? options.pattern; + 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); diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts index 7a7d33a28a..0d7425ad41 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli/src/modules/translations/index.ts @@ -65,12 +65,6 @@ export default createCliPlugin({ default: 'src/translations/resources.ts', description: 'Output path for the generated wiring module', }, - pattern: { - type: 'string', - default: DEFAULT_MESSAGE_PATTERN, - description: - 'File path pattern for message files, with {id} and {lang} placeholders', - }, }) .help() .parse(args); diff --git a/packages/cli/src/modules/translations/lib/discoverPackages.ts b/packages/cli/src/modules/translations/lib/discoverPackages.ts index b8793accd5..32c54da297 100644 --- a/packages/cli/src/modules/translations/lib/discoverPackages.ts +++ b/packages/cli/src/modules/translations/lib/discoverPackages.ts @@ -17,22 +17,11 @@ import { BackstagePackageJson, PackageGraph, - PackageRole, + PackageRoles, } from '@backstage/cli-node'; import { dirname, resolve as resolvePath } from 'node:path'; import fs from 'fs-extra'; -/** - * Package roles that can contain frontend translation refs. - */ -const FRONTEND_ROLES: PackageRole[] = [ - 'frontend', - 'frontend-plugin', - 'frontend-plugin-module', - 'web-library', - 'common-library', -]; - /** A discovered package with its entry points resolved to file paths. */ export interface DiscoveredPackage { /** The package name, e.g. '@backstage/plugin-org' */ @@ -141,7 +130,7 @@ export async function discoverFrontendPackages( } const role = depPkgJson.backstage?.role; - if (role && FRONTEND_ROLES.includes(role)) { + if (role && isFrontendRole(role)) { const entryPoints = resolveEntryPoints(depPkgJson, depDir, isWorkspace); if (entryPoints.size > 0) { result.push({ name: depName, dir: depDir, entryPoints }); @@ -208,3 +197,11 @@ function resolveEntryPoints( return entryPoints; } + +function isFrontendRole(role: string): boolean { + try { + return PackageRoles.getRoleInfo(role).platform === 'web'; + } catch { + return false; + } +} From dd5f0520176199c71a6b9161cd8bf74f19d68937 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 12:32:51 +0100 Subject: [PATCH 06/11] cli: make messages/ part of the configurable --pattern flag Moves the messages/ path segment from being hardcoded into the export and import commands to being part of the pattern itself. The default pattern is now messages/{id}.{lang}.json, giving full control over the directory structure under the translations output directory. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- docs/plugins/internationalization.md | 6 +++--- docs/tooling/cli/03-commands.md | 5 +++-- .../modules/translations/commands/export.ts | 12 ++++-------- .../modules/translations/commands/import.ts | 18 ++++++------------ .../translations/lib/messageFilePath.test.ts | 18 ++++++++++-------- .../translations/lib/messageFilePath.ts | 6 +++--- 6 files changed, 29 insertions(+), 36 deletions(-) diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index f1d5d8a81d..0c62058e5a 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -382,7 +382,7 @@ const app = createApp({ #### Custom file patterns -By default, message files use the pattern `{id}.{lang}.json` (e.g. `catalog.en.json`). You can change this with the `--pattern` option: +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' @@ -391,8 +391,8 @@ yarn backstage-cli translations export --pattern '{lang}/{id}.json' This produces a directory structure grouped by language instead: ```text -translations/messages/en/catalog.json -translations/messages/zh/catalog.json +translations/en/catalog.json +translations/zh/catalog.json ``` The pattern is stored in the manifest, so the `import` command automatically uses the same layout. diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index efdf197113..44b9058f19 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -447,8 +447,9 @@ Usage: backstage-cli translations export [options] Options: --output Output directory for exported messages and manifest (default: "translations") - --pattern File path pattern for message files, with {id} and {lang} - placeholders (default: "{id}.{lang}.json") + --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 ``` diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index ca8bd2c60b..cf6630ae39 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -39,12 +39,8 @@ export default async (options: ExportOptions) => { paths.targetRoot, ); - const messagesDir = resolvePath(paths.targetDir, options.output, 'messages'); - const manifestPath = resolvePath( - paths.targetDir, - options.output, - 'manifest.json', - ); + const outputDir = resolvePath(paths.targetDir, options.output); + const manifestPath = resolvePath(outputDir, 'manifest.json'); const tsconfigPath = paths.resolveTargetRoot('tsconfig.json'); if (!(await fs.pathExists(tsconfigPath))) { @@ -104,7 +100,7 @@ export default async (options: ExportOptions) => { ref.id, DEFAULT_LANGUAGE, ); - const filePath = resolvePath(messagesDir, relPath); + const filePath = resolvePath(outputDir, relPath); await fs.ensureDir(dirname(filePath)); await fs.writeJson(filePath, ref.messages, { spaces: 2 }); } @@ -132,6 +128,6 @@ export default async (options: ExportOptions) => { console.log( `\nExported ${allRefs.length} translation ref(s) to ${options.output}/`, ); - console.log(` Messages: ${options.output}/messages/${examplePath}`); + 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 index 35b554ce30..cab2030d1f 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -48,7 +48,6 @@ export default async (options: ImportOptions) => { await readTargetPackage(paths.targetDir, paths.targetRoot); const inputDir = resolvePath(paths.targetDir, options.input); - const messagesDir = resolvePath(inputDir, 'messages'); const manifestPath = resolvePath(inputDir, 'manifest.json'); const outputPath = resolvePath(paths.targetDir, options.output); @@ -59,13 +58,6 @@ export default async (options: ImportOptions) => { ); } - if (!(await fs.pathExists(messagesDir))) { - throw new Error( - `No messages directory found at ${messagesDir}. ` + - 'Run "backstage-cli translations export" first.', - ); - } - const manifest: Manifest = await fs.readJson(manifestPath); if (!manifest.pattern) { @@ -77,8 +69,10 @@ export default async (options: ImportOptions) => { const parsePath = createMessagePathParser(pattern); - // Discover all JSON files under the messages directory - const allFiles = await collectJsonFiles(messagesDir); + // 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< @@ -120,7 +114,7 @@ export default async (options: ImportOptions) => { console.log('No translated message files found.'); const example = formatMessagePath(pattern, '', 'sv'); console.log( - `Add translated files as messages/${example} in the translations directory.`, + `Add translated files as ${example} in the translations directory.`, ); return; } @@ -150,7 +144,7 @@ export default async (options: ImportOptions) => { const jsonRelPath = posixPath.normalize( relativePath( resolvePath(outputPath, '..'), - resolvePath(messagesDir, relPath), + resolvePath(inputDir, relPath), ), ); return ` ${JSON.stringify(lang)}: () => import('./${jsonRelPath}'),`; diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.test.ts b/packages/cli/src/modules/translations/lib/messageFilePath.test.ts index 6a6f3a8873..4c276e114c 100644 --- a/packages/cli/src/modules/translations/lib/messageFilePath.test.ts +++ b/packages/cli/src/modules/translations/lib/messageFilePath.test.ts @@ -26,13 +26,13 @@ describe('messageFilePath', () => { describe('formatMessagePath', () => { it('formats the default pattern', () => { expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'org', 'en')).toBe( - 'org.en.json', + 'messages/org.en.json', ); }); it('formats with a different language', () => { expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'catalog', 'sv')).toBe( - 'catalog.sv.json', + 'messages/catalog.sv.json', ); }); @@ -52,12 +52,12 @@ describe('messageFilePath', () => { describe('createMessagePathParser', () => { it('parses the default pattern', () => { const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN); - expect(parse('org.en.json')).toEqual({ id: 'org', lang: 'en' }); + 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('plugin.notifications.sv.json')).toEqual({ + expect(parse('messages/plugin.notifications.sv.json')).toEqual({ id: 'plugin.notifications', lang: 'sv', }); @@ -71,7 +71,7 @@ describe('messageFilePath', () => { it('returns undefined for non-matching paths', () => { const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN); expect(parse('not-a-match.txt')).toBeUndefined(); - expect(parse('dir/org.en.json')).toBeUndefined(); + expect(parse('other/org.en.json')).toBeUndefined(); }); it('returns undefined for invalid language code', () => { @@ -100,7 +100,9 @@ describe('messageFilePath', () => { describe('messagePatternToGlob', () => { it('converts the default pattern', () => { - expect(messagePatternToGlob(DEFAULT_MESSAGE_PATTERN)).toBe('*.*.json'); + expect(messagePatternToGlob(DEFAULT_MESSAGE_PATTERN)).toBe( + 'messages/*.*.json', + ); }); it('converts a language-directory pattern', () => { @@ -109,8 +111,8 @@ describe('messageFilePath', () => { }); describe('patternHasSubdirectories', () => { - it('returns false for flat patterns', () => { - expect(patternHasSubdirectories(DEFAULT_MESSAGE_PATTERN)).toBe(false); + it('returns true for the default pattern', () => { + expect(patternHasSubdirectories(DEFAULT_MESSAGE_PATTERN)).toBe(true); }); it('returns true for patterns with directories', () => { diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.ts b/packages/cli/src/modules/translations/lib/messageFilePath.ts index 5f84c1ad13..0eb2547759 100644 --- a/packages/cli/src/modules/translations/lib/messageFilePath.ts +++ b/packages/cli/src/modules/translations/lib/messageFilePath.ts @@ -17,9 +17,9 @@ // The default language for exported translation messages. export const DEFAULT_LANGUAGE = 'en'; -// Default file path pattern for translation message files. -// Supported placeholders: {id} (ref ID) and {lang} (language code). -export const DEFAULT_MESSAGE_PATTERN = '{id}.{lang}.json'; +// 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( From cbb50c73da8592d79173f87764e62b77360aa5d0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 12:37:27 +0100 Subject: [PATCH 07/11] changeset: use patch bump for translations CLI commands Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .changeset/cli-translations-export-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cli-translations-export-import.md b/.changeset/cli-translations-export-import.md index 38377d23ef..6631eebdfd 100644 --- a/.changeset/cli-translations-export-import.md +++ b/.changeset/cli-translations-export-import.md @@ -1,5 +1,5 @@ --- -'@backstage/cli': minor +'@backstage/cli': patch --- Added `translations export` and `translations import` commands for managing translation files. From c76d42020d8af44a6d1bf05ba00da874f29273d4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 12:43:03 +0100 Subject: [PATCH 08/11] docs: remove references to specific external translation systems Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- docs/plugins/internationalization.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index 0c62058e5a..2770f76799 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -298,7 +298,7 @@ Go to the Settings page — you should see language switching buttons. Switch la ### Using the CLI for full translation workflows -When translating your app to other languages at scale — especially when working with translation management systems (TMS) like Smartling, Crowdin, or Lokalise — the Backstage CLI provides `translations export` and `translations import` commands that automate the extraction and wiring of translation messages across all your plugin dependencies. +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 @@ -397,13 +397,13 @@ translations/zh/catalog.json The pattern is stored in the manifest, so the `import` command automatically uses the same layout. -#### Integration with a TMS +#### Integration with external translation systems -The exported JSON files are standard key-value pairs compatible with most translation management systems. A typical workflow looks like: +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 TMS -3. Download the translated files from your TMS into the `messages/` directory +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). From 00472ed0be69712d6db29c37d4316379f7577e78 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 15:06:41 +0100 Subject: [PATCH 09/11] cli: update CLI report for translations commands Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- packages/cli/cli-report.md | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 8979e91a82..03938dd6e2 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 ``` @@ -541,6 +542,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` ``` From 90f19f6164121bb2d76c12a8c83206e2ed146b76 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Feb 2026 23:04:32 +0100 Subject: [PATCH 10/11] cli: address review feedback on translations commands - Fix Windows path separators in import by splitting on path.sep - Make extractTranslations test less brittle by not pinning exact message strings or total count - Remove unnecessary `as any` cast for devDependencies - Validate pattern in export command to fail fast on invalid patterns Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .../modules/translations/commands/export.ts | 8 +++++++- .../modules/translations/commands/import.ts | 14 ++++++------- .../translations/lib/discoverPackages.ts | 2 +- .../lib/extractTranslations.test.ts | 20 ++++++++----------- .../translations/lib/messageFilePath.ts | 2 +- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index cf6630ae39..96b3ceb0c6 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -26,7 +26,11 @@ import { extractTranslationRefsFromSourceFile, TranslationRefInfo, } from '../lib/extractTranslations'; -import { DEFAULT_LANGUAGE, formatMessagePath } from '../lib/messageFilePath'; +import { + DEFAULT_LANGUAGE, + formatMessagePath, + validatePattern, +} from '../lib/messageFilePath'; interface ExportOptions { output: string; @@ -34,6 +38,8 @@ interface ExportOptions { } export default async (options: ExportOptions) => { + validatePattern(options.pattern); + const targetPackageJson = await readTargetPackage( paths.targetDir, paths.targetRoot, diff --git a/packages/cli/src/modules/translations/commands/import.ts b/packages/cli/src/modules/translations/commands/import.ts index cab2030d1f..7275da4c24 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -19,7 +19,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath, relative as relativePath, - posix as posixPath, + sep, } from 'node:path'; import { readTargetPackage } from '../lib/discoverPackages'; import { @@ -141,12 +141,12 @@ export default async (options: ImportOptions) => { const translationEntries = entries .sort((a, b) => a.lang.localeCompare(b.lang)) .map(({ lang, relPath }) => { - const jsonRelPath = posixPath.normalize( - relativePath( - resolvePath(outputPath, '..'), - resolvePath(inputDir, relPath), - ), - ); + const jsonRelPath = relativePath( + resolvePath(outputPath, '..'), + resolvePath(inputDir, relPath), + ) + .split(sep) + .join('/'); return ` ${JSON.stringify(lang)}: () => import('./${jsonRelPath}'),`; }) .join('\n'); diff --git a/packages/cli/src/modules/translations/lib/discoverPackages.ts b/packages/cli/src/modules/translations/lib/discoverPackages.ts index 32c54da297..c7a7602a7e 100644 --- a/packages/cli/src/modules/translations/lib/discoverPackages.ts +++ b/packages/cli/src/modules/translations/lib/discoverPackages.ts @@ -92,7 +92,7 @@ export async function discoverFrontendPackages( ) { const deps: Record = { ...packageJson.dependencies, - ...(includeDevDeps ? (packageJson as any).devDependencies : {}), + ...(includeDevDeps ? packageJson.devDependencies ?? {} : {}), }; for (const depName of Object.keys(deps)) { diff --git a/packages/cli/src/modules/translations/lib/extractTranslations.test.ts b/packages/cli/src/modules/translations/lib/extractTranslations.test.ts index 8b46c63eff..27b67db052 100644 --- a/packages/cli/src/modules/translations/lib/extractTranslations.test.ts +++ b/packages/cli/src/modules/translations/lib/extractTranslations.test.ts @@ -44,21 +44,17 @@ describe('extractTranslations', () => { exportName: 'orgTranslationRef', }); - // Verify a subset of messages - expect(refs[0].messages).toMatchObject({ - 'groupProfileCard.groupNotFound': 'Group not found', - 'membersListCard.title': 'Members', - 'membersListCard.subtitle': 'of {{groupName}}', - 'userProfileCard.userNotFound': 'User not found', - }); + 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.paginationLabel']).toBe( - ', page {{page}} of {{nbPages}}', + expect(refs[0].messages['membersListCard.subtitle']).toContain( + '{{groupName}}', ); - - // Verify total message count - expect(Object.keys(refs[0].messages).length).toBe(26); }); it('ignores non-TranslationRef exports', () => { diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.ts b/packages/cli/src/modules/translations/lib/messageFilePath.ts index 0eb2547759..eb3dc8ea5c 100644 --- a/packages/cli/src/modules/translations/lib/messageFilePath.ts +++ b/packages/cli/src/modules/translations/lib/messageFilePath.ts @@ -64,7 +64,7 @@ export function patternHasSubdirectories(pattern: string): boolean { return pattern.includes('/'); } -function validatePattern(pattern: string) { +export function validatePattern(pattern: string) { if (!pattern.includes('{id}')) { throw new Error( `Invalid message file pattern: must contain {id} placeholder. Got: ${pattern}`, From 1dfc3436d9a104e18e9b8ba04cb8b01ba07e4d9d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 23 Feb 2026 01:05:15 +0100 Subject: [PATCH 11/11] cli: address review feedback from freben Fix whitespace alignment in CLI command summary, improve export command description to clarify it operates from an app context, and update the corresponding docs section. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- docs/tooling/cli/03-commands.md | 6 +++--- packages/cli/src/modules/translations/index.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index 44b9058f19..7bea50588a 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -24,7 +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 +translations [command] Translation message management clean Delete cache directories [DEPRECATED] build-workspace [packages...] Builds a temporary dist workspace from the provided packages @@ -431,8 +431,8 @@ Usage: backstage-cli create-github-app ## translations export -Export translation messages from all frontend plugin dependencies of the current -package. This command must be run from within a package directory (e.g. +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, diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts index 0d7425ad41..702b0f4f49 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli/src/modules/translations/index.ts @@ -24,7 +24,7 @@ export default createCliPlugin({ reg.addCommand({ path: ['translations', 'export'], description: - 'Export translation messages from all frontend plugins to JSON files', + 'Export translation messages from an app and all of its frontend plugins to JSON files', execute: async ({ args }) => { const argv = await yargs() .options({