From d404ab30cafaae74d2fbe9c832f30f71481b3ee7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Feb 2026 23:29:31 +0100 Subject: [PATCH] cli: walk transitive deps and resolve npm packages for translation export The package discovery for the translations export command previously only looked at direct dependencies and only workspace packages. This changes it to recursively walk the full transitive dependency tree and resolve packages from node_modules when they aren't found in the workspace. Entry point resolution now also prefers .d.ts type declarations for npm-installed packages. Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .../modules/translations/commands/export.ts | 5 +- .../translations/lib/discoverPackages.ts | 127 +++++++++++++----- 2 files changed, 95 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index 5366cdfeab..e8371a7655 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -55,7 +55,10 @@ export default async (options: ExportOptions) => { console.log( `Discovering frontend dependencies of ${targetPackageJson.name}...`, ); - const packages = await discoverFrontendPackages(targetPackageJson); + const packages = await discoverFrontendPackages( + targetPackageJson, + paths.targetDir, + ); console.log(`Found ${packages.length} frontend packages to scan`); console.log('Creating TypeScript project...'); diff --git a/packages/cli/src/modules/translations/lib/discoverPackages.ts b/packages/cli/src/modules/translations/lib/discoverPackages.ts index 895c254933..b8793accd5 100644 --- a/packages/cli/src/modules/translations/lib/discoverPackages.ts +++ b/packages/cli/src/modules/translations/lib/discoverPackages.ts @@ -19,7 +19,7 @@ import { PackageGraph, PackageRole, } from '@backstage/cli-node'; -import { resolve as resolvePath } from 'node:path'; +import { dirname, resolve as resolvePath } from 'node:path'; import fs from 'fs-extra'; /** @@ -71,54 +71,103 @@ export async function readTargetPackage( } /** - * Discovers frontend packages that are dependencies of the given target - * package and resolves their entry point file paths. + * 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 { - 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([ - ...Object.keys(targetPackageJson.dependencies ?? {}), - ...Object.keys((targetPackageJson as any).devDependencies ?? {}), - ]); + // 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(); const result: DiscoveredPackage[] = []; - for (const depName of depNames) { - const pkg = packagesByName.get(depName); - if (!pkg) { - continue; - } + async function visit( + packageJson: BackstagePackageJson, + pkgDir: string, + includeDevDeps: boolean, + ) { + const deps: Record = { + ...packageJson.dependencies, + ...(includeDevDeps ? (packageJson as any).devDependencies : {}), + }; - const role = pkg.packageJson.backstage?.role; - if (!role || !FRONTEND_ROLES.includes(role)) { - continue; - } + for (const depName of Object.keys(deps)) { + if (visited.has(depName)) { + continue; + } + visited.add(depName); - const entryPoints = resolveEntryPoints(pkg.packageJson, pkg.dir); - if (entryPoints.size > 0) { - result.push({ - name: pkg.packageJson.name, - dir: pkg.dir, - entryPoints, - }); + 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 && FRONTEND_ROLES.includes(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. - * Uses the `exports` field from package.json, falling back to `main`/`types`. + * 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 { const entryPoints = new Map(); @@ -128,24 +177,30 @@ function resolveEntryPoints( 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; + 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 to main/types - const main = packageJson.types ?? packageJson.main; + // 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)); }