From d27fec81b433f7f73b454c04a0b380747e94257e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 13:02:59 +0000 Subject: [PATCH] fix(native-federation): resolve @angular/common/locales/global/ bare specifier Angular's @angular/build:application builder injects a polyfill `angular:locale/data:` whenever `i18n.sourceLocale` is explicitly defined and not en/en-US. In dev-server mode with `packages: 'external'`, the i18n-locale-plugin leaves the resulting `@angular/common/locales/global/` import as a bare specifier so vite's dep-prebundling can resolve it at runtime. Native Federation replaces vite's externals/prebundling with its own importmap (via es-module-shims), but did not surface the locale data file there. The browser therefore failed to resolve the bare specifier with "Failed to resolve module specifier '@angular/common/locales/global/'". Inject the matching locale data files as shared chunks into the federation config after normalization (so filterShared does not strip them again), so the bundler emits the files and writeImportMap lists them. Mirrors Angular's progressive locale-tag fallback (de-XYZ -> de). Also covers the dev-server case where a single inline locale is served via the --localize filter. https://claude.ai/code/session_01R6oaYzTRcwyo5JUWGqWZHv --- .../src/builders/build/builder.ts | 20 +- libs/native-federation/src/utils/i18n.spec.ts | 232 ++++++++++++++++++ libs/native-federation/src/utils/i18n.ts | 166 ++++++++++++- 3 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 libs/native-federation/src/utils/i18n.spec.ts diff --git a/libs/native-federation/src/builders/build/builder.ts b/libs/native-federation/src/builders/build/builder.ts index ba9ce036..e1eefa30 100644 --- a/libs/native-federation/src/builders/build/builder.ts +++ b/libs/native-federation/src/builders/build/builder.ts @@ -43,7 +43,11 @@ import { } from '../../utils/mem-resuts'; import { FederationInfo } from '@softarc/native-federation-runtime'; import { PluginBuild } from 'esbuild'; -import { getI18nConfig, translateFederationArtefacts } from '../../utils/i18n'; +import { + getI18nConfig, + registerAngularLocaleDataInFederationConfig, + translateFederationArtefacts, +} from '../../utils/i18n'; import { RebuildHubs } from '../../utils/rebuild-events'; import { createSharedMappingsPlugin } from '../../utils/shared-mappings-plugin'; import { updateScriptTags } from '../../utils/updateIndexHtml'; @@ -235,6 +239,20 @@ export async function* runBuilder( const config = await loadFederationConfig(fedOptions); logger.measure(start, 'To load the federation config.'); + // When `i18n.sourceLocale` (or an inline locale) resolves to a non-English + // code, Angular's application builder emits bare specifiers of the form + // `@angular/common/locales/global/` that normally rely on vite's + // dep-prebundling at dev time. Native Federation replaces that resolution + // layer, so we have to surface those locale data files through the + // federation's importmap explicitly. + const inlineLocaleFilter = Array.isArray(localeFilter) ? localeFilter : []; + registerAngularLocaleDataInFederationConfig( + config, + i18n, + context.workspaceRoot, + inlineLocaleFilter, + ); + const externals = getExternals(config); const plugins = [ createSharedMappingsPlugin(config.sharedMappings), diff --git a/libs/native-federation/src/utils/i18n.spec.ts b/libs/native-federation/src/utils/i18n.spec.ts new file mode 100644 index 00000000..3b567034 --- /dev/null +++ b/libs/native-federation/src/utils/i18n.spec.ts @@ -0,0 +1,232 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// The full `@softarc/native-federation/build` barrel pulls in `chalk` (ESM-only), +// which jest cannot parse in the default config. We only need a minimal logger +// surface in i18n.ts, so stub the barrel here. +jest.mock('@softarc/native-federation/build', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + notice: jest.fn(), + measure: jest.fn(), + }, +})); + +import type { NormalizedFederationConfig } from '@softarc/native-federation/build'; + +import { + I18nConfig, + registerAngularLocaleDataInFederationConfig, + resolveAngularLocaleData, +} from './i18n'; + +function makeFakeWorkspace(locales: string[], version = '17.3.0'): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-i18n-spec-')); + const globalDir = path.join( + root, + 'node_modules', + '@angular', + 'common', + 'locales', + 'global', + ); + fs.mkdirSync(globalDir, { recursive: true }); + for (const locale of locales) { + fs.writeFileSync(path.join(globalDir, `${locale}.js`), '/* fake */'); + } + fs.writeFileSync( + path.join(root, 'node_modules', '@angular', 'common', 'package.json'), + JSON.stringify({ name: '@angular/common', version }), + ); + return root; +} + +function emptyConfig(): NormalizedFederationConfig { + return { + name: 'test', + exposes: {}, + shared: {}, + sharedMappings: [], + skip: { strings: new Set(), functions: [], regexps: [] }, + externals: [], + features: { mappingVersion: false, ignoreUnusedDeps: false }, + }; +} + +describe('resolveAngularLocaleData', () => { + it('returns null for built-in english locales', () => { + const root = makeFakeWorkspace(['de-CH']); + expect(resolveAngularLocaleData('en', root)).toBeNull(); + expect(resolveAngularLocaleData('en-US', root)).toBeNull(); + }); + + it('resolves an exact match', () => { + const root = makeFakeWorkspace(['de-CH'], '17.3.0'); + const result = resolveAngularLocaleData('de-CH', root); + expect(result).not.toBeNull(); + expect(result!.packageName).toBe('@angular/common/locales/global/de-CH'); + expect(result!.matchedCode).toBe('de-CH'); + expect(result!.entryPoint).toBe( + 'node_modules/@angular/common/locales/global/de-CH.js', + ); + expect(result!.version).toBe('17.3.0'); + }); + + it('falls back to a shorter locale tag when the exact code is missing', () => { + // Mirrors angular's i18n-locale-plugin behaviour: de-XYZ → de + const root = makeFakeWorkspace(['de']); + const result = resolveAngularLocaleData('de-XYZ', root); + expect(result).not.toBeNull(); + expect(result!.matchedCode).toBe('de'); + expect(result!.packageName).toBe('@angular/common/locales/global/de'); + }); + + it('returns null when the locale cannot be resolved at all', () => { + const root = makeFakeWorkspace([]); + expect(resolveAngularLocaleData('de-CH', root)).toBeNull(); + }); +}); + +describe('registerAngularLocaleDataInFederationConfig', () => { + it('does nothing when i18n is undefined', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const registered = registerAngularLocaleDataInFederationConfig( + config, + undefined, + root, + ); + expect(registered).toEqual([]); + expect(Object.keys(config.shared)).toEqual([]); + }); + + it('does nothing for a default en-US source locale', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { sourceLocale: 'en-US', locales: {} }; + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + expect(registered).toEqual([]); + expect(Object.keys(config.shared)).toEqual([]); + }); + + it('registers a shared entry for an object-form non-english sourceLocale', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH', baseHref: '/de/' }, + locales: {}, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + + expect(registered).toEqual(['@angular/common/locales/global/de-CH']); + const entry = config.shared['@angular/common/locales/global/de-CH']; + expect(entry).toBeDefined(); + expect(entry.platform).toBe('browser'); + expect(entry.build).toBe('default'); + expect(entry.packageInfo?.entryPoint).toBe( + 'node_modules/@angular/common/locales/global/de-CH.js', + ); + }); + + it('also handles the string-form sourceLocale (regression: bug is not specific to object form)', () => { + const root = makeFakeWorkspace(['fr-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { sourceLocale: 'fr-CH', locales: {} }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + + expect(registered).toEqual(['@angular/common/locales/global/fr-CH']); + }); + + it('registers inline locales requested via the dev-server locale filter', () => { + const root = makeFakeWorkspace(['de-CH', 'fr-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH' }, + locales: { 'fr-CH': { translation: 'messages.fr-CH.xlf' } }, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ['fr-CH'], + ); + + expect(new Set(registered)).toEqual( + new Set([ + '@angular/common/locales/global/de-CH', + '@angular/common/locales/global/fr-CH', + ]), + ); + }); + + it('does not overwrite an entry the user already configured', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const userEntry = { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + platform: 'browser' as const, + build: 'default' as const, + packageInfo: { + entryPoint: 'custom/path.js', + version: '0.0.0', + esm: true, + }, + }; + config.shared['@angular/common/locales/global/de-CH'] = userEntry; + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH' }, + locales: {}, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + + expect(registered).toEqual([]); + expect(config.shared['@angular/common/locales/global/de-CH']).toBe( + userEntry, + ); + }); + + it('skips locales that cannot be resolved on disk but still processes the rest', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH' }, + locales: {}, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ['xx-YY'], // not present on disk + ); + + expect(registered).toEqual(['@angular/common/locales/global/de-CH']); + }); +}); diff --git a/libs/native-federation/src/utils/i18n.ts b/libs/native-federation/src/utils/i18n.ts index 763dd085..09ba24ec 100644 --- a/libs/native-federation/src/utils/i18n.ts +++ b/libs/native-federation/src/utils/i18n.ts @@ -1,5 +1,8 @@ import { BuilderContext } from '@angular-devkit/architect'; -import { logger } from '@softarc/native-federation/build'; +import { + logger, + NormalizedFederationConfig, +} from '@softarc/native-federation/build'; import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; @@ -132,3 +135,164 @@ function ensureDistFolders(locales: string[], outputPath: string) { fs.mkdirSync(localePath, { recursive: true }); } } + +const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +// Angular's framework ships `en`/`en-US` data inline; the locale-data plugin +// short-circuits these and never emits a bare specifier for them. +// See: @angular/build/src/tools/esbuild/i18n-locale-plugin.ts +function isBuiltInEnglishLocale(code: string): boolean { + return code === 'en' || code === 'en-US'; +} + +export type ResolvedLocaleData = { + /** Bare specifier emitted by Angular, e.g. "@angular/common/locales/global/de-CH" */ + packageName: string; + /** Path to the locale data file, relative to the workspace root */ + entryPoint: string; + /** The locale tag that actually matched on disk (may be a prefix of the request) */ + matchedCode: string; + /** Version of @angular/common (for cache busting / packageInfo) */ + version: string; +}; + +/** + * Resolves the `@angular/common/locales/global/` file on disk, mirroring + * Angular's own progressive locale-tag fallback (de-CH → de). + */ +export function resolveAngularLocaleData( + code: string, + workspaceRoot: string, +): ResolvedLocaleData | null { + if (!code || isBuiltInEnglishLocale(code)) { + return null; + } + + const angularCommonRoot = path.join( + workspaceRoot, + 'node_modules', + '@angular', + 'common', + ); + let version = '0.0.0'; + const pkgJsonPath = path.join(angularCommonRoot, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + try { + version = + JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version ?? version; + } catch { + // ignore – fall back to the placeholder version + } + } + + let partial = code; + while (partial) { + for (const ext of ['.js', '.mjs']) { + const rel = path.posix.join( + 'node_modules', + '@angular', + 'common', + 'locales', + 'global', + `${partial}${ext}`, + ); + const abs = path.join(workspaceRoot, rel); + if (fs.existsSync(abs)) { + return { + packageName: `${LOCALE_DATA_BASE_MODULE}/${partial}`, + entryPoint: rel, + matchedCode: partial, + version, + }; + } + } + + const parts = partial.split('-'); + if (parts.length <= 1) { + break; + } + partial = parts.slice(0, -1).join('-'); + } + + return null; +} + +/** + * When Angular's `@angular/build:application` builder is configured with a + * non-English `i18n.sourceLocale` (or runs a single inline locale via the dev + * server), its esbuild i18n-locale-plugin emits bare external imports of the + * form `@angular/common/locales/global/` that vite's dep-prebundling is + * expected to resolve at runtime. Native Federation replaces that resolution + * layer with its own importmap, so the bare specifier ends up unresolved in + * the browser. + * + * This helper compensates by injecting the locale data files as shared chunks + * into the federation config *after* normalization (i.e. after `filterShared` + * has already run), so the bundler emits them and `writeImportMap` lists + * them. + * + * Returns the list of package names that were registered, primarily for + * diagnostics and tests. + */ +export function registerAngularLocaleDataInFederationConfig( + config: NormalizedFederationConfig, + i18n: I18nConfig | undefined, + workspaceRoot: string, + inlineLocales: readonly string[] = [], +): string[] { + if (!i18n) { + return []; + } + + const sourceCode = + typeof i18n.sourceLocale === 'string' + ? i18n.sourceLocale + : i18n.sourceLocale?.code; + + const candidates = new Set(); + if (sourceCode) { + candidates.add(sourceCode); + } + for (const loc of inlineLocales) { + candidates.add(loc); + } + + const registered: string[] = []; + + for (const code of candidates) { + if (isBuiltInEnglishLocale(code)) { + continue; + } + + const resolved = resolveAngularLocaleData(code, workspaceRoot); + if (!resolved) { + logger.warn( + `Could not locate '${LOCALE_DATA_BASE_MODULE}/${code}' in node_modules. ` + + `The browser will not be able to resolve this bare specifier at runtime. ` + + `Verify that @angular/common is installed, or share the locale data manually via federation.config.js.`, + ); + continue; + } + + if (config.shared[resolved.packageName]) { + // User has already shared this entry explicitly – leave it alone. + continue; + } + + config.shared[resolved.packageName] = { + singleton: true, + strictVersion: false, + requiredVersion: 'auto', + platform: 'browser', + build: 'default', + packageInfo: { + entryPoint: resolved.entryPoint, + version: resolved.version, + esm: true, + }, + }; + registered.push(resolved.packageName); + } + + return registered; +}