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