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, + }); +}