Merge pull request #32904 from backstage/rugvip/translation-export-import

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