Merge pull request #32904 from backstage/rugvip/translation-export-import
cli: add translations export and import commands
This commit is contained in:
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`
|
||||
|
||||
```
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user