Add translations export and translations import CLI commands
Adds tooling for exporting and importing translation strings to enable integration with external TMS tools like Smartling. The export command uses the TypeScript compiler API (ts-morph) to discover TranslationRef exports from all frontend plugin dependencies of the current package, extracting message keys and default values from the type system. The import command generates TranslationResource wiring from translated JSON files downloaded from a TMS. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,119 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { paths } from '../../../lib/paths';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import {
|
||||
discoverFrontendPackages,
|
||||
readTargetPackage,
|
||||
} from '../lib/discoverPackages';
|
||||
import {
|
||||
createTranslationProject,
|
||||
extractTranslationRefsFromSourceFile,
|
||||
TranslationRefInfo,
|
||||
} from '../lib/extractTranslations';
|
||||
|
||||
interface ExportOptions {
|
||||
output: string;
|
||||
}
|
||||
|
||||
export default async (options: ExportOptions) => {
|
||||
const targetPackageJson = await readTargetPackage(
|
||||
paths.targetDir,
|
||||
paths.targetRoot,
|
||||
);
|
||||
|
||||
const outputDir = resolvePath(paths.targetDir, options.output, 'messages');
|
||||
const manifestPath = resolvePath(
|
||||
paths.targetDir,
|
||||
options.output,
|
||||
'manifest.json',
|
||||
);
|
||||
|
||||
const tsconfigPath = paths.resolveTargetRoot('tsconfig.json');
|
||||
if (!(await fs.pathExists(tsconfigPath))) {
|
||||
throw new Error(
|
||||
`No tsconfig.json found at ${tsconfigPath}. ` +
|
||||
'The translations export command requires a tsconfig.json in the repo root.',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Discovering frontend dependencies of ${targetPackageJson.name}...`,
|
||||
);
|
||||
const packages = await discoverFrontendPackages(targetPackageJson);
|
||||
console.log(`Found ${packages.length} frontend packages to scan`);
|
||||
|
||||
console.log('Creating TypeScript project...');
|
||||
const project = createTranslationProject(tsconfigPath);
|
||||
|
||||
const allRefs: TranslationRefInfo[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
for (const [exportPath, filePath] of pkg.entryPoints) {
|
||||
try {
|
||||
const sourceFile = project.addSourceFileAtPath(filePath);
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
pkg.name,
|
||||
exportPath,
|
||||
);
|
||||
allRefs.push(...refs);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
` Warning: failed to process ${pkg.name} (${exportPath}): ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allRefs.length === 0) {
|
||||
console.log('No translation refs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${allRefs.length} translation ref(s):`);
|
||||
for (const ref of allRefs) {
|
||||
const messageCount = Object.keys(ref.messages).length;
|
||||
console.log(` ${ref.id} (${ref.packageName}, ${messageCount} messages)`);
|
||||
}
|
||||
|
||||
// Write message files
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
for (const ref of allRefs) {
|
||||
const filePath = resolvePath(outputDir, `${ref.id}.en.json`);
|
||||
await fs.writeJson(filePath, ref.messages, { spaces: 2 });
|
||||
}
|
||||
|
||||
// Write manifest
|
||||
const manifest: Record<string, object> = {};
|
||||
for (const ref of allRefs) {
|
||||
manifest[ref.id] = {
|
||||
package: ref.packageName,
|
||||
exportPath: ref.exportPath,
|
||||
exportName: ref.exportName,
|
||||
};
|
||||
}
|
||||
await fs.writeJson(manifestPath, { refs: manifest }, { spaces: 2 });
|
||||
|
||||
console.log(
|
||||
`\nExported ${allRefs.length} translation ref(s) to ${options.output}/`,
|
||||
);
|
||||
console.log(` Messages: ${options.output}/messages/<ref-id>.en.json`);
|
||||
console.log(` Manifest: ${options.output}/manifest.json`);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { paths } from '../../../lib/paths';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath, relative as relativePath } from 'node:path';
|
||||
import { readTargetPackage } from '../lib/discoverPackages';
|
||||
|
||||
interface ImportOptions {
|
||||
input: string;
|
||||
output: string;
|
||||
}
|
||||
|
||||
interface ManifestRefEntry {
|
||||
package: string;
|
||||
exportPath: string;
|
||||
exportName: string;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
refs: Record<string, ManifestRefEntry>;
|
||||
}
|
||||
|
||||
export default async (options: ImportOptions) => {
|
||||
await readTargetPackage(paths.targetDir, paths.targetRoot);
|
||||
|
||||
const inputDir = resolvePath(paths.targetDir, options.input);
|
||||
const messagesDir = resolvePath(inputDir, 'messages');
|
||||
const manifestPath = resolvePath(inputDir, 'manifest.json');
|
||||
const outputPath = resolvePath(paths.targetDir, options.output);
|
||||
|
||||
if (!(await fs.pathExists(manifestPath))) {
|
||||
throw new Error(
|
||||
`No manifest.json found at ${manifestPath}. ` +
|
||||
'Run "backstage-cli translations export" first.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(messagesDir))) {
|
||||
throw new Error(
|
||||
`No messages directory found at ${messagesDir}. ` +
|
||||
'Run "backstage-cli translations export" first.',
|
||||
);
|
||||
}
|
||||
|
||||
const manifest: Manifest = await fs.readJson(manifestPath);
|
||||
|
||||
// Find all translated (non-English) message files
|
||||
const files = await fs.readdir(messagesDir);
|
||||
const translatedFiles = files.filter(
|
||||
f => f.endsWith('.json') && !f.endsWith('.en.json'),
|
||||
);
|
||||
|
||||
if (translatedFiles.length === 0) {
|
||||
console.log('No translated message files found.');
|
||||
console.log(
|
||||
'Add translated files as <ref-id>.<language>.json in the messages/ directory.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group translations by ref ID
|
||||
const translationsByRef = new Map<string, string[]>();
|
||||
for (const file of translatedFiles) {
|
||||
// Expected format: <ref-id>.<language>.json
|
||||
const match = file.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
|
||||
if (!match) {
|
||||
console.warn(` Warning: skipping file with unexpected name: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, refId, language] = match;
|
||||
if (!manifest.refs[refId]) {
|
||||
console.warn(
|
||||
` Warning: skipping ${file} - ref '${refId}' not found in manifest`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = translationsByRef.get(refId) ?? [];
|
||||
existing.push(language);
|
||||
translationsByRef.set(refId, existing);
|
||||
}
|
||||
|
||||
if (translationsByRef.size === 0) {
|
||||
console.log('No valid translation files found for known refs.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate the wiring module
|
||||
const importLines: string[] = [];
|
||||
const resourceLines: string[] = [];
|
||||
|
||||
importLines.push(
|
||||
"import { createTranslationResource } from '@backstage/frontend-plugin-api';",
|
||||
);
|
||||
|
||||
for (const [refId, languages] of [...translationsByRef.entries()].sort(
|
||||
([a], [b]) => a.localeCompare(b),
|
||||
)) {
|
||||
const refEntry = manifest.refs[refId];
|
||||
const importPath =
|
||||
refEntry.exportPath === '.'
|
||||
? refEntry.package
|
||||
: `${refEntry.package}/${refEntry.exportPath.replace(/^\.\//, '')}`;
|
||||
|
||||
importLines.push(`import { ${refEntry.exportName} } from '${importPath}';`);
|
||||
|
||||
const messagesRelPath = relativePath(
|
||||
resolvePath(outputPath, '..'),
|
||||
messagesDir,
|
||||
);
|
||||
|
||||
const translationEntries = languages
|
||||
.sort()
|
||||
.map(
|
||||
lang =>
|
||||
` ${JSON.stringify(
|
||||
lang,
|
||||
)}: () => import('./${messagesRelPath}/${refId}.${lang}.json'),`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
resourceLines.push(
|
||||
[
|
||||
` createTranslationResource({`,
|
||||
` ref: ${refEntry.exportName},`,
|
||||
` translations: {`,
|
||||
translationEntries,
|
||||
` },`,
|
||||
` }),`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const fileContent = [
|
||||
'// This file is auto-generated by backstage-cli translations import',
|
||||
'// Do not edit manually.',
|
||||
'',
|
||||
...importLines,
|
||||
'',
|
||||
'export default [',
|
||||
...resourceLines,
|
||||
'];',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await fs.ensureDir(resolvePath(outputPath, '..'));
|
||||
await fs.writeFile(outputPath, fileContent, 'utf8');
|
||||
|
||||
console.log(`Generated translation resources at ${options.output}`);
|
||||
console.log(
|
||||
` ${translationsByRef.size} ref(s), ${translatedFiles.length} translation file(s)`,
|
||||
);
|
||||
console.log(
|
||||
'\nImport this file in your app and pass the resources to your translation API setup.',
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import yargs from 'yargs';
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
|
||||
export default createCliPlugin({
|
||||
pluginId: 'translations',
|
||||
init: async reg => {
|
||||
reg.addCommand({
|
||||
path: ['translations', 'export'],
|
||||
description:
|
||||
'Export translation messages from all frontend plugins to JSON files',
|
||||
execute: async ({ args }) => {
|
||||
const argv = await yargs()
|
||||
.options({
|
||||
output: {
|
||||
type: 'string',
|
||||
default: 'translations',
|
||||
description:
|
||||
'Output directory for exported messages and manifest',
|
||||
},
|
||||
})
|
||||
.help()
|
||||
.parse(args);
|
||||
await lazy(() => import('./commands/export'), 'default')(argv);
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['translations', 'import'],
|
||||
description:
|
||||
'Generate translation resource wiring from translated JSON files',
|
||||
execute: async ({ args }) => {
|
||||
const argv = await yargs()
|
||||
.options({
|
||||
input: {
|
||||
type: 'string',
|
||||
default: 'translations',
|
||||
description:
|
||||
'Input directory containing the manifest and translated message files',
|
||||
},
|
||||
output: {
|
||||
type: 'string',
|
||||
default: 'src/translations/resources.ts',
|
||||
description: 'Output path for the generated wiring module',
|
||||
},
|
||||
})
|
||||
.help()
|
||||
.parse(args);
|
||||
await lazy(() => import('./commands/import'), 'default')(argv);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstagePackageJson,
|
||||
PackageGraph,
|
||||
PackageRole,
|
||||
} from '@backstage/cli-node';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/**
|
||||
* Package roles that can contain frontend translation refs.
|
||||
*/
|
||||
const FRONTEND_ROLES: PackageRole[] = [
|
||||
'frontend',
|
||||
'frontend-plugin',
|
||||
'frontend-plugin-module',
|
||||
'web-library',
|
||||
'common-library',
|
||||
];
|
||||
|
||||
/** A discovered package with its entry points resolved to file paths. */
|
||||
export interface DiscoveredPackage {
|
||||
/** The package name, e.g. '@backstage/plugin-org' */
|
||||
name: string;
|
||||
/** The directory of the package */
|
||||
dir: string;
|
||||
/** Map of export subpath (e.g. '.', './alpha') to the resolved file path */
|
||||
entryPoints: Map<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 dependencies of the given target
|
||||
* package and resolves their entry point file paths.
|
||||
*/
|
||||
export async function discoverFrontendPackages(
|
||||
targetPackageJson: BackstagePackageJson,
|
||||
): Promise<DiscoveredPackage[]> {
|
||||
const allPackages = await PackageGraph.listTargetPackages();
|
||||
const packagesByName = new Map(allPackages.map(p => [p.packageJson.name, p]));
|
||||
|
||||
// Collect all direct dependencies of the target package
|
||||
const depNames = new Set<string>([
|
||||
...Object.keys(targetPackageJson.dependencies ?? {}),
|
||||
...Object.keys((targetPackageJson as any).devDependencies ?? {}),
|
||||
]);
|
||||
|
||||
const result: DiscoveredPackage[] = [];
|
||||
|
||||
for (const depName of depNames) {
|
||||
const pkg = packagesByName.get(depName);
|
||||
if (!pkg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = pkg.packageJson.backstage?.role;
|
||||
if (!role || !FRONTEND_ROLES.includes(role)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPoints = resolveEntryPoints(pkg.packageJson, pkg.dir);
|
||||
if (entryPoints.size > 0) {
|
||||
result.push({
|
||||
name: pkg.packageJson.name,
|
||||
dir: pkg.dir,
|
||||
entryPoints,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the entry points of a package to absolute file paths.
|
||||
* Uses the `exports` field from package.json, falling back to `main`/`types`.
|
||||
*/
|
||||
function resolveEntryPoints(
|
||||
packageJson: BackstagePackageJson,
|
||||
packageDir: string,
|
||||
): Map<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)) {
|
||||
// Skip package.json entry
|
||||
if (subpath === './package.json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The target can be a string or a conditions map
|
||||
const filePath =
|
||||
typeof target === 'string'
|
||||
? target
|
||||
: target?.import ?? target?.types ?? target?.default;
|
||||
|
||||
if (typeof filePath === 'string') {
|
||||
entryPoints.set(subpath, resolvePath(packageDir, filePath));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to main/types
|
||||
const main = packageJson.types ?? packageJson.main;
|
||||
if (main) {
|
||||
entryPoints.set('.', resolvePath(packageDir, main));
|
||||
}
|
||||
}
|
||||
|
||||
return entryPoints;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import {
|
||||
createTranslationProject,
|
||||
extractTranslationRefsFromSourceFile,
|
||||
} from './extractTranslations';
|
||||
|
||||
describe('extractTranslations', () => {
|
||||
it('extracts translation refs from the org plugin', () => {
|
||||
const project = createTranslationProject(
|
||||
resolvePath(__dirname, '../../../../../../tsconfig.json'),
|
||||
);
|
||||
|
||||
const sourceFile = project.addSourceFileAtPath(
|
||||
resolvePath(__dirname, '../../../../../..', 'plugins/org/src/alpha.tsx'),
|
||||
);
|
||||
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
'@backstage/plugin-org',
|
||||
'./alpha',
|
||||
);
|
||||
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]).toMatchObject({
|
||||
id: 'org',
|
||||
packageName: '@backstage/plugin-org',
|
||||
exportPath: './alpha',
|
||||
exportName: 'orgTranslationRef',
|
||||
});
|
||||
|
||||
// Verify a subset of messages
|
||||
expect(refs[0].messages).toMatchObject({
|
||||
'groupProfileCard.groupNotFound': 'Group not found',
|
||||
'membersListCard.title': 'Members',
|
||||
'membersListCard.subtitle': 'of {{groupName}}',
|
||||
'userProfileCard.userNotFound': 'User not found',
|
||||
});
|
||||
|
||||
// Verify interpolation placeholders are preserved
|
||||
expect(refs[0].messages['membersListCard.paginationLabel']).toBe(
|
||||
', page {{page}} of {{nbPages}}',
|
||||
);
|
||||
|
||||
// Verify total message count
|
||||
expect(Object.keys(refs[0].messages).length).toBe(26);
|
||||
});
|
||||
|
||||
it('ignores non-TranslationRef exports', () => {
|
||||
const project = createTranslationProject(
|
||||
resolvePath(__dirname, '../../../../../../tsconfig.json'),
|
||||
);
|
||||
|
||||
// The main entry of org plugin exports components but no translation ref
|
||||
const sourceFile = project.addSourceFileAtPath(
|
||||
resolvePath(__dirname, '../../../../../..', 'plugins/org/src/index.ts'),
|
||||
);
|
||||
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
'@backstage/plugin-org',
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('extracts from the test fixtures translation ref', () => {
|
||||
const project = createTranslationProject(
|
||||
resolvePath(__dirname, '../../../../../../tsconfig.json'),
|
||||
);
|
||||
|
||||
const sourceFile = project.addSourceFileAtPath(
|
||||
resolvePath(
|
||||
__dirname,
|
||||
'../../../../../..',
|
||||
'packages/frontend-plugin-api/src/translation/__fixtures__/refs.ts',
|
||||
),
|
||||
);
|
||||
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
'@backstage/frontend-plugin-api',
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(refs).toHaveLength(2);
|
||||
|
||||
const counting = refs.find(r => r.id === 'counting');
|
||||
expect(counting).toMatchObject({
|
||||
messages: {
|
||||
one: 'one',
|
||||
two: 'two',
|
||||
three: 'three',
|
||||
},
|
||||
});
|
||||
|
||||
const fruits = refs.find(r => r.id === 'fruits');
|
||||
expect(fruits).toMatchObject({
|
||||
messages: {
|
||||
apple: 'apple',
|
||||
orange: 'orange',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user