Split CLI modules into separate packages
Extract each CLI module from packages/cli/src/modules/ into its own package under packages/cli-module-*. This enables independent versioning and clearer dependency boundaries for each CLI capability. Module mapping: - auth → @backstage/cli-module-auth - build → @backstage/cli-module-build - config → @backstage/cli-module-config - create-github-app → @backstage/cli-module-create-github-app - info → @backstage/cli-module-info - lint → @backstage/cli-module-lint - maintenance → @backstage/cli-module-maintenance - migrate → @backstage/cli-module-migrate - new → @backstage/cli-module-new - test → @backstage/cli-module-test-jest - translations → @backstage/cli-module-translations Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-cli-module-translations
|
||||
title: '@backstage/cli-module-translations'
|
||||
description: CLI module for Backstage CLI
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-cli-module
|
||||
owner: tooling-maintainers
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@backstage/cli-module-translations",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI module for Backstage CLI",
|
||||
"backstage": {
|
||||
"role": "cli-module"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/cli-module-translations"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli-node": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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 { cli } from 'cleye';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
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,
|
||||
DEFAULT_MESSAGE_PATTERN,
|
||||
formatMessagePath,
|
||||
validatePattern,
|
||||
} from '../lib/messageFilePath';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { output, pattern },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
booleanFlagNegation: true,
|
||||
flags: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const options = { output, pattern };
|
||||
|
||||
validatePattern(options.pattern);
|
||||
|
||||
const targetPackageJson = await readTargetPackage(
|
||||
targetPaths.dir,
|
||||
targetPaths.rootDir,
|
||||
);
|
||||
|
||||
const outputDir = resolvePath(targetPaths.dir, options.output);
|
||||
const manifestPath = resolvePath(outputDir, 'manifest.json');
|
||||
|
||||
const tsconfigPath = targetPaths.resolveRoot('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,
|
||||
targetPaths.dir,
|
||||
);
|
||||
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,236 @@
|
||||
/*
|
||||
* 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 { cli } from 'cleye';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
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';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
|
||||
interface ManifestRefEntry {
|
||||
package: string;
|
||||
exportPath: string;
|
||||
exportName: string;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
pattern?: string;
|
||||
refs: Record<string, ManifestRefEntry>;
|
||||
}
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { input, output },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
booleanFlagNegation: true,
|
||||
flags: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const options = { input, output };
|
||||
await readTargetPackage(targetPaths.dir, targetPaths.rootDir);
|
||||
|
||||
const inputDir = resolvePath(targetPaths.dir, options.input);
|
||||
const manifestPath = resolvePath(inputDir, 'manifest.json');
|
||||
const outputPath = resolvePath(targetPaths.dir, 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,36 @@
|
||||
/*
|
||||
* 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 { createCliModule } from '@backstage/cli-node';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
export default createCliModule({
|
||||
packageJson,
|
||||
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: { loader: () => import('./commands/export') },
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['translations', 'import'],
|
||||
description:
|
||||
'Generate translation resource wiring from translated JSON files',
|
||||
execute: { loader: () => import('./commands/import') },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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.title']).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